diff --git a/.github/workflows/julia-release.yml b/.github/workflows/julia-release.yml index 21e544c23..1a143efe1 100644 --- a/.github/workflows/julia-release.yml +++ b/.github/workflows/julia-release.yml @@ -16,6 +16,8 @@ permissions: env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" CARGO_HTTP_MULTIPLEXING: "false" CARGO_NET_GIT_FETCH_WITH_CLI: "true" CARGO_NET_RETRY: "10" @@ -140,35 +142,48 @@ jobs: - name: Set up Rust run: rustup show - - name: Install LLVM 14.0.6 using pecos-llvm (Unix) + - name: Install LLVM 21.1 (Unix) if: runner.os != 'Windows' run: | - echo "Installing LLVM using pecos..." - cargo run --locked -p pecos-cli --release -- install llvm + if [ "$RUNNER_OS" = "macOS" ]; then + echo "Installing LLVM 21 with Homebrew..." + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + LLVM_PREFIX="$(brew --prefix llvm@21)" + else + echo "Installing LLVM 21.1 using PECOS-managed packages..." + cargo run --locked -p pecos-cli --release -- llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-conda-linux.sh + LLVM_PREFIX="$HOME/.pecos/deps/llvm-21.1" + fi echo "Setting LLVM environment variables..." - export PECOS_LLVM=$(cargo run --locked -p pecos-cli --release -- llvm find 2>/dev/null) - export LLVM_SYS_140_PREFIX="$PECOS_LLVM" + cargo run --locked -p pecos-cli --release -- llvm configure "$LLVM_PREFIX" + export PECOS_LLVM="$LLVM_PREFIX" + export LLVM_SYS_211_PREFIX="$LLVM_PREFIX" echo "PECOS_LLVM=$PECOS_LLVM" >> $GITHUB_ENV - echo "LLVM_SYS_140_PREFIX=$LLVM_SYS_140_PREFIX" >> $GITHUB_ENV + echo "LLVM_SYS_211_PREFIX=$LLVM_SYS_211_PREFIX" >> $GITHUB_ENV echo "Verifying LLVM installation..." cargo run --locked -p pecos-cli --release -- llvm check - - name: Install LLVM 14.0.6 using pecos-llvm (Windows) + - name: Install LLVM 21.1 (Windows) if: runner.os == 'Windows' shell: pwsh run: | - Write-Host "Installing LLVM using pecos..." - cargo run --locked -p pecos-cli --release -- install llvm + $llvmRoot = Join-Path $env:USERPROFILE ".pecos\deps\llvm-21.1" + $llvmPrefix = Join-Path $llvmRoot "Library" + Write-Host "Installing conda-forge LLVM 21.1..." + ./scripts/ci/install-llvm-21-windows.ps1 -InstallDir $llvmRoot -Version ${{ env.LLVM_RELEASE_VERSION }} Write-Host "Setting LLVM environment variables..." - $env:PECOS_LLVM = (cargo run --locked -p pecos-cli --release -- llvm find 2>$null) - $env:LLVM_SYS_140_PREFIX = $env:PECOS_LLVM + $env:PECOS_LLVM = $llvmPrefix + $env:LLVM_SYS_211_PREFIX = $env:PECOS_LLVM + $env:LIBCLANG_PATH = Join-Path $llvmPrefix "bin" + cargo run --locked -p pecos-cli --release -- llvm configure "$env:PECOS_LLVM" "PECOS_LLVM=$env:PECOS_LLVM" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "LLVM_SYS_140_PREFIX=$env:LLVM_SYS_140_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "LLVM_SYS_211_PREFIX=$env:LLVM_SYS_211_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "LIBCLANG_PATH=$env:LIBCLANG_PATH" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "Verifying LLVM installation..." cargo run --locked -p pecos-cli --release -- llvm check diff --git a/.github/workflows/julia-test.yml b/.github/workflows/julia-test.yml index 1c7bcf62e..63d43685e 100644 --- a/.github/workflows/julia-test.yml +++ b/.github/workflows/julia-test.yml @@ -77,7 +77,7 @@ jobs: # NOTE: LLVM is not currently needed for Julia FFI since we use pecos with default-features = false # Keeping this commented out in case we need to re-enable LLVM features in the future. # - # - name: Install LLVM 14.0.6 using pecos (Unix) + # - name: Install LLVM 21.1 using pecos (Unix) # if: runner.os != 'Windows' # run: | # echo "Installing LLVM using pecos..." @@ -85,15 +85,15 @@ jobs: # # echo "Setting LLVM environment variables..." # export PECOS_LLVM=$(cargo run -p pecos-cli --release -- llvm find 2>/dev/null) - # export LLVM_SYS_140_PREFIX="$PECOS_LLVM" + # export LLVM_SYS_211_PREFIX="$PECOS_LLVM" # # echo "PECOS_LLVM=$PECOS_LLVM" >> $GITHUB_ENV - # echo "LLVM_SYS_140_PREFIX=$LLVM_SYS_140_PREFIX" >> $GITHUB_ENV + # echo "LLVM_SYS_211_PREFIX=$LLVM_SYS_211_PREFIX" >> $GITHUB_ENV # # echo "Verifying LLVM installation..." # cargo run -p pecos-cli --release -- llvm check # - # - name: Install LLVM 14.0.6 using pecos (Windows) + # - name: Install LLVM 21.1 using pecos (Windows) # if: runner.os == 'Windows' # shell: pwsh # run: | @@ -102,10 +102,10 @@ jobs: # # Write-Host "Setting LLVM environment variables..." # $env:PECOS_LLVM = (cargo run -p pecos-cli --release -- llvm find 2>$null) - # $env:LLVM_SYS_140_PREFIX = $env:PECOS_LLVM + # $env:LLVM_SYS_211_PREFIX = $env:PECOS_LLVM # # "PECOS_LLVM=$env:PECOS_LLVM" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - # "LLVM_SYS_140_PREFIX=$env:LLVM_SYS_140_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # "LLVM_SYS_211_PREFIX=$env:LLVM_SYS_211_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # # Write-Host "Verifying LLVM installation..." # cargo run -p pecos-cli --release -- llvm check diff --git a/.github/workflows/pr-core-gate.yml b/.github/workflows/pr-core-gate.yml index fd93d6d26..1e3cab254 100644 --- a/.github/workflows/pr-core-gate.yml +++ b/.github/workflows/pr-core-gate.yml @@ -19,7 +19,8 @@ permissions: contents: read env: - LLVM_VERSION: "14.0.6" + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 PYTHONUTF8: 1 @@ -127,8 +128,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} if: steps.detect.outputs.run == 'true' @@ -229,8 +230,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} if: steps.detect.outputs.run == 'true' diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index e5fe19535..da0291fb4 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -5,6 +5,9 @@ permissions: env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" + MACOS_WHEEL_DEPLOYMENT_TARGET: "15.0" CARGO_HTTP_MULTIPLEXING: "false" CARGO_NET_GIT_FETCH_WITH_CLI: "true" CARGO_NET_RETRY: "10" @@ -160,8 +163,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-14.0.6-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM run: just ci-env @@ -251,20 +254,16 @@ jobs: fail-fast: false matrix: include: - # Linux x86_64 with CUDA support (uses GCC Toolset 13 for CUDA compatibility) + # Linux x86_64 with CUDA support. The manylinux 2.34 image's default + # compiler is GCC 14, which emits libstdc++ symbols newer than + # auditwheel's manylinux_2_35 policy allows. Install and pin the + # AlmaLinux system GCC 11 toolchain in the Linux build environment. - os: ubuntu-latest architecture: x86_64 cibw_archs: x86_64 install_cuda: true - # GCC Toolset 13 paths for CUDA compatibility - gcc_path_prefix: "/opt/rh/gcc-toolset-13/root/usr/bin:" - gcc_ld_path: "/opt/rh/gcc-toolset-13/root/usr/lib64:/opt/rh/gcc-toolset-13/root/usr/lib:" - gcc_cc: "/opt/rh/gcc-toolset-13/root/usr/bin/gcc" - gcc_cxx: "/opt/rh/gcc-toolset-13/root/usr/bin/g++" - # Linux aarch64 - DISABLED: LLVM 14 not available in manylinux_2_28 (AlmaLinux 8) - # The prebuilt LLVM 14 binary is incompatible with the container, and - # the llvm-toolset module only provides LLVM 13. Re-enable when we have - # a solution (custom Docker image, build from source, or inkwell LLVM 13 support) + # Linux aarch64 - temporarily disabled while the LLVM 21.1 + # manylinux_2_34 bootstrap is validated on the primary x86_64 lane. # - os: ubuntu-24.04-arm # architecture: aarch64 # runner: ubuntu-24.04-arm @@ -370,54 +369,70 @@ jobs: CIBW_BUILD: "cp310-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux*" CIBW_ARCHS_LINUX: ${{ matrix.cibw_archs }} - CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_28" - CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_28" - # Linux configuration - GCC Toolset and CUDA paths are conditional via matrix variables + CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_34" + CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_34" + # Linux configuration - CUDA paths are conditional via matrix variables CIBW_ENVIRONMENT_LINUX: > - PATH=${{ matrix.gcc_path_prefix }}$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH - LD_LIBRARY_PATH=${{ matrix.gcc_ld_path }}$LD_LIBRARY_PATH - LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 + PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH + LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 + LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin/cmake CUDA_PATH=/usr/local/cuda-12.6 + CC=/usr/bin/gcc + CXX=/usr/bin/g++ + CUDAHOSTCXX=/usr/bin/g++ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH - dnf install libffi-devel -y + dnf install -y bzip2 gcc gcc-c++ libffi-devel xz + export CC=/usr/bin/gcc + export CXX=/usr/bin/g++ + export CUDAHOSTCXX=/usr/bin/g++ + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc # Install CUDA Toolkit for GPU support on x86_64 (compile-time only, no GPU needed) if [ "${{ matrix.install_cuda }}" = "true" ]; then - echo "Installing GCC 13 (required for CUDA 12.6 compatibility)..." - dnf install -y gcc-toolset-13 - source /opt/rh/gcc-toolset-13/enable echo "Installing CUDA Toolkit from NVIDIA repos..." - dnf config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo + . /etc/os-release + CUDA_RHEL_MAJOR="${VERSION_ID%%.*}" + dnf config-manager --add-repo "https://developer.download.nvidia.com/compute/cuda/repos/rhel${CUDA_RHEL_MAJOR}/x86_64/cuda-rhel${CUDA_RHEL_MAJOR}.repo" dnf install -y cuda-nvcc-12-6 cuda-cudart-devel-12-6 libcublas-devel-12-6 export CUDA_PATH=/usr/local/cuda-12.6 export PATH=$CUDA_PATH/bin:$PATH echo "CUDA installed at $CUDA_PATH" nvcc --version - gcc --version else echo "Skipping CUDA installation (GPU support not enabled for this build)" fi - cargo run --locked --release -p pecos-cli -- install llvm --force + "$CC" --version + "$CXX" --version + bash scripts/ci/install-llvm-21-conda-linux.sh + cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" cargo run --locked --release -p pecos-cli -- install cmake --force CIBW_REPAIR_WHEEL_COMMAND_LINUX: > - auditwheel repair -w {dest_dir} {wheel} && + LD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH + auditwheel repair --plat manylinux_2_35_${{ matrix.cibw_archs }} -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} # macOS configuration CIBW_ENVIRONMENT_MACOS: > - PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin:$PATH - LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 + PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin:$PATH + LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 + LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin/cmake - MACOSX_DEPLOYMENT_TARGET=13.2 + MACOSX_DEPLOYMENT_TARGET=${{ env.MACOS_WHEEL_DEPLOYMENT_TARGET }} SDKROOT=$(xcrun --show-sdk-path) MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_MACOS: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH rustup update - cargo run --locked --release -p pecos-cli -- install llvm --force + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + mkdir -p "$HOME/.pecos/deps" + ln -sfn "$(brew --prefix llvm@21)" "$HOME/.pecos/deps/llvm-21.1" + cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" cargo run --locked --release -p pecos-cli -- install cmake --force # Create a codesign wrapper that strips DYLD_LIBRARY_PATH to prevent # crashes on macOS 15 when bundled libc++ conflicts with system libc++ @@ -425,21 +440,23 @@ jobs: printf '#!/bin/bash\nunset DYLD_LIBRARY_PATH\nexec /usr/bin/codesign "$@"\n' > $HOME/.pecos/bin/codesign chmod +x $HOME/.pecos/bin/codesign CIBW_REPAIR_WHEEL_COMMAND_MACOS: > - PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-14/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && + PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && pipx run abi3audit --strict --report {wheel} # Windows configuration - CUDA via Jimver/cuda-toolkit (installed before cibuildwheel) CIBW_ENVIRONMENT_WINDOWS: > - PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" - LLVM_SYS_140_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14" + PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" + LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library" + LIBCLANG_PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin" CMAKE="C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin\\cmake.exe" MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_WINDOWS: > - echo "=== Installing LLVM using pecos ===" && + echo "=== Installing conda-forge LLVM 21.1 ===" && rustup update && - cargo run --locked --release -p pecos-cli -- install llvm --force && + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }} && + cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library" && cargo run --locked --release -p pecos-cli -- install cmake --force && echo "=== Checking LLVM installation ===" && - (test -d "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14" && echo "LLVM directory exists") || (echo "ERROR: LLVM directory not found!" && exit 1) + if exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library\bin\llvm-config.exe" (echo LLVM directory exists) else (echo ERROR: LLVM directory not found! && exit /b 1) # Install delvewheel and patch it to ignore ext-ms-win-* API sets # (delvewheel ignores api-ms-win-* but not ext-ms-win-* which are also Windows API sets) CIBW_BEFORE_BUILD_WINDOWS: > @@ -448,7 +465,7 @@ jobs: # Note: --no-dll excludes Windows system DLLs that should not be bundled # combase.dll and rmclient.dll are core Windows components that fail when bundled CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && + delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} - name: Upload pecos-rslib wheels @@ -468,32 +485,50 @@ jobs: CIBW_BUILD: "cp310-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux*" CIBW_ARCHS_LINUX: ${{ matrix.cibw_archs }} - CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_28" - CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_28" + CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_34" + CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_34" CIBW_ENVIRONMENT_LINUX: > - PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$PATH - LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 + PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH + LD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH + LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 + LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib + CC=/usr/bin/gcc + CXX=/usr/bin/g++ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH - dnf install libffi-devel -y - cargo run --locked --release -p pecos-cli -- install llvm --force + dnf install -y bzip2 gcc gcc-c++ libffi-devel xz + export CC=/usr/bin/gcc + export CXX=/usr/bin/g++ + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + "$CC" --version + "$CXX" --version + bash scripts/ci/install-llvm-21-conda-linux.sh + cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" CIBW_REPAIR_WHEEL_COMMAND_LINUX: > - auditwheel repair -w {dest_dir} {wheel} && + LD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH + auditwheel repair --plat manylinux_2_35_${{ matrix.cibw_archs }} -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_ENVIRONMENT_MACOS: > - 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 + PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH + LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 + LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib + MACOSX_DEPLOYMENT_TARGET=${{ env.MACOS_WHEEL_DEPLOYMENT_TARGET }} SDKROOT=$(xcrun --show-sdk-path) CIBW_BEFORE_ALL_MACOS: | if ! command -v cargo >/dev/null 2>&1; then bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH fi - if [ ! -d "$HOME/.pecos/deps/llvm-14/bin" ]; then + if [ ! -d "$HOME/.pecos/deps/llvm-21.1/bin" ]; then rustup update - cargo run --locked --release -p pecos-cli -- install llvm --force + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + mkdir -p "$HOME/.pecos/deps" + ln -sfn "$(brew --prefix llvm@21)" "$HOME/.pecos/deps/llvm-21.1" + cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" else echo "LLVM already installed from pecos-rslib build, skipping" fi @@ -501,19 +536,21 @@ jobs: printf '#!/bin/bash\nunset DYLD_LIBRARY_PATH\nexec /usr/bin/codesign "$@"\n' > $HOME/.pecos/bin/codesign chmod +x $HOME/.pecos/bin/codesign CIBW_REPAIR_WHEEL_COMMAND_MACOS: > - PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-14/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && + PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_ENVIRONMENT_WINDOWS: > - PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14\\bin;$PATH" - LLVM_SYS_140_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14" + PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin;$PATH" + LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library" + LIBCLANG_PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin" CIBW_BEFORE_ALL_WINDOWS: > rustup update && - if not exist "C:\Users\runneradmin\.pecos\deps\llvm-14\bin" (cargo run --locked --release -p pecos-cli -- install llvm --force) else (echo LLVM already installed from pecos-rslib build) + if not exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library\bin\llvm-config.exe" (powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }}) else (echo LLVM already installed from pecos-rslib build) && + cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library" CIBW_BEFORE_BUILD_WINDOWS: > pip install delvewheel && python -c "import delvewheel._dll_list as d,inspect,re as r;p=inspect.getfile(d);c=open(p).read();n=chr(10);open(p,'w').write(c.replace(r\"re.compile('api-.*'),\",r\"re.compile('api-.*'),\"+n+r\" re.compile('ext-.*'),\")) if 'ext-.*' not in c else None" CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && + delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} - name: Upload pecos-rslib-llvm wheels diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 2dbe2d889..955b29e07 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -7,10 +7,14 @@ env: RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 PYTHONUTF8: 1 + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" + # Force the MWPF decoder feature on in CI so we exercise the cmake-dependent + # build path and catch regressions. GitHub-hosted runners ship cmake. + PECOS_BUILD_MWPF: "1" CARGO_HTTP_MULTIPLEXING: "false" CARGO_NET_GIT_FETCH_WITH_CLI: "true" CARGO_NET_RETRY: "10" - LLVM_VERSION: "14.0.6" on: push: @@ -112,8 +116,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -122,7 +126,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Sync Python dependencies @@ -198,8 +202,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} if: github.event_name != 'pull_request' @@ -209,7 +213,7 @@ jobs: if: github.event_name != 'pull_request' && steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Run core Python tests (post-merge) @@ -262,6 +266,14 @@ jobs: tool-cache: false swap-storage: true + - name: Free Disk Space (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Remove-Item -Path "C:\Android" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path "C:\Program Files\dotnet" -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path "C:\hostedtoolcache\CodeQL" -Recurse -Force -ErrorAction SilentlyContinue + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: @@ -313,8 +325,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -323,7 +335,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Install Python build tooling @@ -444,8 +456,8 @@ jobs: if: matrix.include-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} if: matrix.include-llvm @@ -455,7 +467,7 @@ jobs: if: matrix.include-llvm && steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Download Python compatibility smoke artifacts @@ -501,6 +513,9 @@ jobs: fi fi + # Post-merge lanes excluded from the PR fast path: the slow integration + # tests and the optional-dependency suite (guppy/selene/QIR runtime tests + # marked `optional_dependency`). python-slow-postmerge: if: github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) runs-on: ubuntu-latest @@ -566,8 +581,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -576,12 +591,15 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Build core Python packages run: just python-ci-build + - name: Run optional-dependency Python tests + run: just pytest-dep + - name: Run slow Python tests run: just pytest-slow @@ -617,6 +635,11 @@ jobs: exit 1 } + # pecos-build (where the toml_edit work lives) and pecos-core are + # publishable and need no LLVM/FFI/external toolchain -- the right + # minimal canary. LLVM-needing crates additionally require the upstream + # llvm-sys LLVM_SYS_211_PREFIX (a universal llvm-sys requirement, not a + # PECOS workaround) and are out of scope for this contract lane. - name: Vanilla cargo check (publishable crates, no just/bootstrap) shell: pwsh run: | diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index ad585b335..5e3dc16dd 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -7,7 +7,8 @@ env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 - LLVM_VERSION: "14.0.6" + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" on: push: @@ -16,9 +17,11 @@ on: - 'crates/**' - 'exp/**' - 'examples/**' + - 'exp/**' - 'julia/pecos-julia-ffi/**' - 'python/pecos-rslib-cuda/**' - 'python/pecos-rslib/**' + - 'python/pecos-rslib-exp/**' - 'python/pecos-rslib-llvm/**' - 'Cargo.toml' - 'Cargo.lock' @@ -33,9 +36,11 @@ on: - 'crates/**' - 'exp/**' - 'examples/**' + - 'exp/**' - 'julia/pecos-julia-ffi/**' - 'python/pecos-rslib-cuda/**' - 'python/pecos-rslib/**' + - 'python/pecos-rslib-exp/**' - 'python/pecos-rslib-llvm/**' - 'Cargo.toml' - 'Cargo.lock' @@ -97,8 +102,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -107,7 +112,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev') uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Check formatting @@ -241,8 +246,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Bootstrap MSVC for the Cargo build path (Windows) if: runner.os == 'Windows' && (github.event_name != 'pull_request' || matrix.os == 'ubuntu-latest') @@ -261,7 +266,7 @@ jobs: if: (github.event_name != 'pull_request' || matrix.os == 'ubuntu-latest') && steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev') uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Install CUDA Toolkit (Linux) diff --git a/.github/workflows/test-docs-examples.yml b/.github/workflows/test-docs-examples.yml index c304cf2b5..fc1b33b20 100644 --- a/.github/workflows/test-docs-examples.yml +++ b/.github/workflows/test-docs-examples.yml @@ -22,7 +22,8 @@ env: RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 PYTHONUTF8: 1 - LLVM_VERSION: "14.0.6" + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" jobs: docs-ci: @@ -84,8 +85,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -94,7 +95,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev') uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Install dependencies and build (post-merge) diff --git a/Cargo.lock b/Cargo.lock index 35b698e71..979fe6f41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,13 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli 0.32.3", +] + [[package]] name = "addr2line" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ - "gimli", + "gimli 0.33.0", ] [[package]] @@ -146,9 +155,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -304,12 +313,98 @@ dependencies = [ "winapi", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "awint" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48b7360a36d335663e8f20b7f439029debf52b5a0a749d6e936405f25045288" +dependencies = [ + "awint_core", + "awint_dag", + "awint_ext", + "awint_macro_internals", + "awint_macros", +] + +[[package]] +name = "awint_core" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8280079e78217ace721501f35dbf551dadf0ab63863504169316cea1bbac872" +dependencies = [ + "awint_internals", + "const_fn", +] + +[[package]] +name = "awint_dag" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73977ba1786a5e127272f08b865b60f5d548576a0925de1daa5703cc9ba78a8d" +dependencies = [ + "awint_ext", + "awint_macro_internals", + "awint_macros", + "smallvec", +] + +[[package]] +name = "awint_ext" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1b30d3640d9f94dd9f55f655773c5238c11be6f95ab36d24308b7ae34c9bbc" +dependencies = [ + "awint_core", + "const_fn", +] + +[[package]] +name = "awint_internals" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173937e3f4e233d93362fc1e28ab23808cb31671a1923033706cb44ed4e5d10c" +dependencies = [ + "const_fn", +] + +[[package]] +name = "awint_macro_internals" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c6a23f64f14c3b566c2c04124015f78c5fb93e8275a6574e0118747ee60de6" +dependencies = [ + "awint_ext", + "proc-macro2", + "triple_arena", +] + +[[package]] +name = "awint_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e50a2862c6002a424fc9bb4982ec33ab679cb4ad04a174e2a52c2ecc3ea4c7" +dependencies = [ + "awint_internals", + "awint_macro_internals", +] + [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -338,6 +433,30 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line 0.25.1", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.22.1" @@ -354,7 +473,7 @@ checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" name = "benchmarks" version = "0.2.0-dev.0" dependencies = [ - "criterion", + "criterion 0.8.2", "cxx", "nalgebra", "num", @@ -375,7 +494,7 @@ dependencies = [ "rand 0.10.1", "rand_xoshiro 0.8.0", "rapidhash", - "wide 1.4.0", + "wide", ] [[package]] @@ -773,7 +892,7 @@ dependencies = [ "atty", "bitflags 1.3.2", "strsim 0.8.0", - "textwrap", + "textwrap 0.11.0", "unicode-width 0.1.14", "vec_map", ] @@ -890,6 +1009,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const_fn" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413d67b29ef1021b4d60f4aa1e925ca031751e213832b4b1d588fae623c05c60" + [[package]] name = "convert_case" version = "0.4.0" @@ -905,6 +1030,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -961,27 +1095,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8628cc4ba7f88a9205a7ee42327697abc61195a1e3d92cfae172d6a946e722e" +checksum = "e06aeba2c965fc446d13c56a6ccb2631b78445d7544543dd9a25289977630914" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d582754487e6c9a065a91c42ccf1bdd8d5977af33468dac5ae9bec0ce88acb3e" +checksum = "ee2d2dde4ec1352715595b5cfa6fe2e5b8ebb9da3457b3ee8db0aa2808c069aa" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb59c81ace12ee7c33074db7903d4d75d1f40b28cd3e8e6f491de57b29129eb9" +checksum = "03b4982ef9fa54ec9eee841e891e7ddc5434be1250e88de31572e000c888f30b" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -989,9 +1123,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25c06993a681be9cf3140798a3d4ac5bec955e7444416a2fdc87fda8567285d" +checksum = "529143118c4eeb58c39ecb02319557d512be6c61348486422974ab8e3906b8a8" dependencies = [ "serde", "serde_derive", @@ -1000,9 +1134,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b61f95c5a211918f5d336254a61a488b36a5818de47a868e8c4658dce9cccc" +checksum = "b7780677247ad3577e3a6a3ebf43f39b325a11d6393db72b2c9968a910d4d13d" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1013,14 +1147,17 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", - "hashbrown 0.16.1", + "gimli 0.33.0", + "hashbrown 0.17.1", "libm", "log", + "postcard", "pulley-interpreter", "regalloc2", "rustc-hash 2.1.2", "serde", + "serde_derive", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasmtime-internal-core", @@ -1028,9 +1165,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b85aa822fce72080d041d7c2cf7c3f5c6ecdea7afae68379ba4ef85269c4fa5" +checksum = "ac9645250416cbf92454fe61160e17e026e0ce405906a54500b114f923ddffc9" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1041,24 +1178,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9fc89326cd072cc19e96892f09b5692c0dfe17cd4da2858ba30c2cd85c0" +checksum = "20ee8d222ff0fd3681791979afbf88586ac9f49010d3db96b3cbe4c96759aee3" [[package]] name = "cranelift-control" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d005320f487e6e8a3edcc7f2fd4f43fcc9946d1013bf206ea649789ac1617fc" +checksum = "591abe6f5312bd2c4220f1b3bead56c2ad00257c52668015ba013b85dcf2a17a" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e62ef34c6e720f347a79ece043e8584e242d168911da640bac654a33a6aaaf5" +checksum = "a5300c49cf940526fe771517b3b3eabd5d0ff164ee61698579cf403fe8d3af3c" dependencies = [ "cranelift-bitset", "serde", @@ -1068,11 +1205,12 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa2ad00399dd47e7e7e33cb1dc23b0e39ed9dcd01e8f026fc37af91655031b8" +checksum = "da4adbf760207fdbbe130f1191cce01cdef66831a9f648b1f39ff2800d126d45" dependencies = [ "cranelift-codegen", + "hashbrown 0.17.1", "log", "smallvec", "target-lexicon", @@ -1080,15 +1218,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c51975ed217b4e8e5a7fd11e9ec83a96104bdff311dddcb505d1d8a9fd7fc6" +checksum = "8315b21ff018226a42a60a4702c2dd75f6447cac26e9bca622e14c22088c2ff5" [[package]] name = "cranelift-native" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9b1889e00da9729d8f8525f3c12998ded86ea709058ff844ebe00b97548de0e" +checksum = "d506ef23a60715bde451b06620b14402166ded3b648454fccbf04f3e46a4aa70" dependencies = [ "cranelift-codegen", "libc", @@ -1097,9 +1235,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.131.1" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a8f82fd5124f009f72167e60139245cd3b56cfd4b53050f22110c48c5f4da1" +checksum = "48ed47e602652e3410f9387fc0db70fefadcee4d78a78881421aabcab4e26b89" [[package]] name = "crc" @@ -1125,6 +1263,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap 4.6.1", + "criterion-plot 0.5.0", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + [[package]] name = "criterion" version = "0.8.2" @@ -1136,7 +1300,7 @@ dependencies = [ "cast", "ciborium", "clap 4.6.1", - "criterion-plot", + "criterion-plot 0.8.2", "itertools 0.13.0", "num-traits", "oorandom", @@ -1150,6 +1314,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "criterion-plot" version = "0.8.2" @@ -1783,6 +1957,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1827,6 +2002,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1974,6 +2158,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2015,6 +2208,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gimli" version = "0.33.0" @@ -2038,96 +2237,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glam" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" - -[[package]] -name = "glam" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" - -[[package]] -name = "glam" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" - -[[package]] -name = "glam" -version = "0.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" - -[[package]] -name = "glam" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" - -[[package]] -name = "glam" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" - -[[package]] -name = "glam" -version = "0.20.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" - -[[package]] -name = "glam" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" - -[[package]] -name = "glam" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" - -[[package]] -name = "glam" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" - -[[package]] -name = "glam" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" - -[[package]] -name = "glam" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" - -[[package]] -name = "glam" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" - -[[package]] -name = "glam" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" - -[[package]] -name = "glam" -version = "0.29.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" - [[package]] name = "glam" version = "0.30.10" @@ -2146,6 +2255,12 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" +[[package]] +name = "glam" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898f5a568a84989b6c0f8caa50a93074b97dbdc58fc6d9543157bb4562758933" + [[package]] name = "glob" version = "0.3.3" @@ -2207,6 +2322,27 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "guppy-zlup" +version = "0.1.0" +dependencies = [ + "clap 4.6.1", + "insta", + "miette", + "notify", + "notify-debouncer-mini", + "pretty_assertions", + "rayon", + "regex", + "rustpython-parser", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "zlup", +] + [[package]] name = "half" version = "2.7.1" @@ -2245,7 +2381,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "rayon", ] [[package]] @@ -2257,8 +2392,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", - "serde", - "serde_core", ] [[package]] @@ -2267,7 +2400,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", + "rayon", + "serde", + "serde_core", ] [[package]] @@ -2323,6 +2461,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hi_sparse_bitset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "244cabe6369d69e97280fccda8e43c82387aa4af83a2c491791ec421cfe94e45" +dependencies = [ + "wide", +] + [[package]] name = "highs" version = "1.6.1" @@ -2392,21 +2539,20 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hugr" -version = "0.25.6" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc11e18017e6ff91448e08e7180e786ca3247c7539de190f01253298442c92a9" +checksum = "e91648ee98355c1d502a0d466eec0b67ca2612f81ef5d261e62fca8aa567ab52" dependencies = [ "hugr-core", "hugr-llvm", "hugr-model", - "hugr-passes", ] [[package]] name = "hugr-core" -version = "0.25.6" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37961239a500fb1eb128b990de804e32902979682263c326694328e36e95f0ad" +checksum = "dbea3356943ad488326fcea465519c25d03ab792198331610d1292c324d209b8" dependencies = [ "base64", "cgmath", @@ -2433,7 +2579,7 @@ dependencies = [ "smallvec", "smol_str", "static_assertions", - "strum 0.27.2", + "strum 0.28.0", "thiserror 2.0.18", "tracing", "typetag", @@ -2442,9 +2588,9 @@ dependencies = [ [[package]] name = "hugr-llvm" -version = "0.25.6" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d903c4878c5329fb754550460421fbdd35825e5ba6d4bf7ae16336b6e9e7c84" +checksum = "de92b58fa5e8e0c2d03aab5a6a9cd50b4021ad5e3c0ca68d6ffeb604833337ca" dependencies = [ "anyhow", "cc", @@ -2457,14 +2603,14 @@ dependencies = [ "petgraph 0.8.3", "portgraph", "rstest 0.26.1", - "strum 0.27.2", + "strum 0.28.0", ] [[package]] name = "hugr-model" -version = "0.25.7" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b422e3b24b6586f683abe93393f8b9b8bb8b0d028723a13d4c48816039935e" +checksum = "9699c56c272a729b110e989d799e63beb6f077b5bcc118cd76c088453693615e" dependencies = [ "base64", "bumpalo", @@ -2482,25 +2628,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "hugr-passes" -version = "0.25.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d566bdb985b053d8b02cfbe443f225a3fbc3ef2656193f5072d1c70b6abfe827" -dependencies = [ - "ascent", - "derive_more 2.1.1", - "fxhash", - "hugr-core", - "itertools 0.14.0", - "pastey", - "petgraph 0.8.3", - "portgraph", - "serde_json", - "strum 0.27.2", - "thiserror 2.0.18", -] - [[package]] name = "hybrid-array" version = "0.4.12" @@ -2747,28 +2874,48 @@ dependencies = [ [[package]] name = "inkwell" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1def4112dfb2ce2993db7027f7acdb43c1f4ee1c70a082a2eef306ed5d0df365" +checksum = "7decbc9dfa45a4a827a6ff7b822c113b1285678a937e84213417d4ca8a095782" dependencies = [ + "bitflags 2.11.1", "inkwell_internals", "libc", "llvm-sys", - "once_cell", "thiserror 2.0.18", ] [[package]] name = "inkwell_internals" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63736175c9a30ea123f7018de9f26163e0b39cd6978990ae486b510c4f3bad69" +checksum = "6cfe97ee860815a90ed17e09639513269e39420a7440f3f4c996f238c514cf8d" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "insta" version = "1.47.2" @@ -2805,6 +2952,18 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -2816,6 +2975,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2831,6 +2996,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2976,6 +3150,26 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lalrpop" version = "0.19.12" @@ -2988,7 +3182,7 @@ dependencies = [ "ena", "is-terminal", "itertools 0.10.5", - "lalrpop-util", + "lalrpop-util 0.19.12", "petgraph 0.6.5", "regex", "regex-syntax 0.6.29", @@ -3007,6 +3201,12 @@ dependencies = [ "regex", ] +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" + [[package]] name = "lazy_static" version = "1.5.0" @@ -3075,6 +3275,26 @@ dependencies = [ "cc", ] +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3095,14 +3315,15 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llvm-sys" -version = "140.1.3" +version = "211.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3dc78e9857c0231ec11e3bdccf63870493fdc7d0570b0ea7d50bf5df0cb1a0c" +checksum = "44007a7a44b73bdd877fa9c9ccef256036511220e90f65b4d50e7a15773c0ee3" dependencies = [ + "anyhow", "cc", "lazy_static", "libc", - "regex", + "regex-lite", "semver", ] @@ -3156,6 +3377,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "lzma-rust" version = "0.1.7" @@ -3178,11 +3412,66 @@ dependencies = [ [[package]] name = "mach2" -version = "0.4.3" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "malachite" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "2fbdf9cb251732db30a7200ebb6ae5d22fe8e11397364416617d2c2cf0c51cb5" dependencies = [ - "libc", + "malachite-base", + "malachite-nz", + "malachite-q", +] + +[[package]] +name = "malachite-base" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea0ed76adf7defc1a92240b5c36d5368cfe9251640dcce5bd2d0b7c1fd87aeb" +dependencies = [ + "hashbrown 0.14.5", + "itertools 0.11.0", + "libm", + "ryu", +] + +[[package]] +name = "malachite-bigint" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d149aaa2965d70381709d9df4c7ee1fc0de1c614a4efc2ee356f5e43d68749f8" +dependencies = [ + "derive_more 1.0.0", + "malachite", + "num-integer", + "num-traits", + "paste", +] + +[[package]] +name = "malachite-nz" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34a79feebb2bc9aa7762047c8e5495269a367da6b5a90a99882a0aeeac1841f7" +dependencies = [ + "itertools 0.11.0", + "libm", + "malachite-base", +] + +[[package]] +name = "malachite-q" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f235d5747b1256b47620f5640c2a17a88c7569eebdf27cd9cb130e1a619191" +dependencies = [ + "itertools 0.11.0", + "malachite-base", + "malachite-nz", ] [[package]] @@ -3216,6 +3505,36 @@ dependencies = [ "rustix", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap 0.16.2", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "min_max_macros" version = "0.1.1" @@ -3238,6 +3557,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -3325,29 +3656,15 @@ dependencies = [ [[package]] name = "nalgebra" -version = "0.34.2" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346" dependencies = [ "approx 0.5.1", - "glam 0.14.0", - "glam 0.15.2", - "glam 0.16.0", - "glam 0.17.3", - "glam 0.18.0", - "glam 0.19.0", - "glam 0.20.5", - "glam 0.21.3", - "glam 0.22.0", - "glam 0.23.0", - "glam 0.24.2", - "glam 0.25.0", - "glam 0.27.0", - "glam 0.28.0", - "glam 0.29.3", "glam 0.30.10", "glam 0.31.1", "glam 0.32.1", + "glam 0.33.1", "matrixmultiply", "nalgebra-macros", "num-complex 0.4.6", @@ -3398,6 +3715,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "rawpointer", + "rayon", ] [[package]] @@ -3411,7 +3729,7 @@ dependencies = [ "num-complex 0.4.6", "num-traits", "py_literal", - "zip", + "zip 2.4.2", ] [[package]] @@ -3451,6 +3769,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" +dependencies = [ + "crossbeam-channel", + "log", + "notify", +] + [[package]] name = "nt-time" version = "0.8.1" @@ -3625,6 +3973,15 @@ dependencies = [ "objc2-metal", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.39.1" @@ -3662,7 +4019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "243ed4464839d68c0b7a7c082b9f25f62b8cbe786054852cda8d2b791e5b17b0" dependencies = [ "lalrpop", - "lalrpop-util", + "lalrpop-util 0.19.12", "logos", "num", "petgraph 0.6.5", @@ -3717,6 +4074,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "page_size" version = "0.6.0" @@ -3778,14 +4141,18 @@ name = "pecos" version = "0.2.0-dev.0" dependencies = [ "assert_cmd", + "criterion 0.8.2", "log", "pecos-core", "pecos-cppsparsestab", + "pecos-decoder-core", "pecos-decoders", "pecos-engines", + "pecos-fusion-blossom", "pecos-hugr", "pecos-hugr-qis", "pecos-llvm", + "pecos-neo", "pecos-num", "pecos-phir", "pecos-phir-json", @@ -3817,10 +4184,11 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", - "toml", - "toml_edit 0.22.27", + "toml 1.1.2+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", "xz2", - "zip", + "zip 8.6.0", + "zstd", ] [[package]] @@ -3920,6 +4288,7 @@ dependencies = [ "ndarray 0.17.2", "pecos-random", "rayon", + "smallvec", "thiserror 2.0.18", ] @@ -3965,6 +4334,7 @@ dependencies = [ "num-bigint", "pecos-core", "pecos-random", + "pecos-results", "pecos-simulators", "rand 0.10.1", "rayon", @@ -3981,7 +4351,7 @@ dependencies = [ "pecos-random", "pecos-simulators", "rand 0.10.1", - "wide 1.4.0", + "wide", ] [[package]] @@ -4052,7 +4422,7 @@ name = "pecos-hugr" version = "0.2.0-dev.0" dependencies = [ "anyhow", - "hugr-core", + "env_logger", "log", "pecos-core", "pecos-engines", @@ -4072,6 +4442,7 @@ name = "pecos-hugr-qis" version = "0.2.0-dev.0" dependencies = [ "anyhow", + "inkwell", "log", "pecos-core", "serde_json", @@ -4142,7 +4513,7 @@ dependencies = [ name = "pecos-neo" version = "0.2.0-dev.0" dependencies = [ - "criterion", + "criterion 0.8.2", "num-complex 0.4.6", "num_cpus", "pecos-core", @@ -4153,6 +4524,7 @@ dependencies = [ "pecos-qasm", "pecos-quantum", "pecos-random", + "pecos-results", "pecos-simulators", "proptest", "rand 0.10.1", @@ -4211,6 +4583,17 @@ dependencies = [ "wat", ] +[[package]] +name = "pecos-phir-pliron" +version = "0.2.0-dev.0" +dependencies = [ + "awint", + "pecos-core", + "pecos-engines", + "pecos-phir", + "pliron", +] + [[package]] name = "pecos-programs" version = "0.2.0-dev.0" @@ -4264,6 +4647,7 @@ dependencies = [ "ndarray 0.17.2", "pecos-core", "pecos-decoder-core", + "pecos-neo", "pecos-num", "pecos-quantum", "pecos-random", @@ -4274,7 +4658,7 @@ dependencies = [ "serde_json", "smallvec", "thiserror 2.0.18", - "wide 1.4.0", + "wide", ] [[package]] @@ -4301,6 +4685,7 @@ dependencies = [ "selene-soft-rz-runtime", "serde", "serde_json", + "sha2 0.11.0", "tempfile", ] @@ -4346,7 +4731,7 @@ dependencies = [ "rand_xoshiro 0.8.0", "random_tester", "rapidhash", - "wide 1.4.0", + "wide", ] [[package]] @@ -4362,6 +4747,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "pecos-results" +version = "0.2.0-dev.0" +dependencies = [ + "bitvec", + "num-bigint", + "pecos-core", + "serde", + "serde_json", +] + [[package]] name = "pecos-rslib" version = "0.2.0-dev.0" @@ -4373,6 +4769,7 @@ dependencies = [ "ndarray 0.17.2", "num-complex 0.4.6", "parking_lot", + "pecos", "pecos-build", "pecos-core", "pecos-cppsparsestab", @@ -4453,7 +4850,7 @@ dependencies = [ "clap 4.6.1", "pecos-core", "pecos-simulators", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4464,7 +4861,7 @@ dependencies = [ "pecos-core", "pecos-simulators", "pecos-stab-tn", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4475,7 +4872,7 @@ dependencies = [ "pecos-core", "pecos-simulators", "pecos-stab-tn", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4485,7 +4882,7 @@ dependencies = [ "anyhow", "pecos-core", "pecos-simulators", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4496,7 +4893,7 @@ dependencies = [ "clap 4.6.1", "pecos-core", "pecos-simulators", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4506,7 +4903,7 @@ dependencies = [ "anyhow", "pecos-core", "pecos-simulators", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4522,7 +4919,7 @@ dependencies = [ "rand 0.10.1", "rayon", "smallvec", - "wide 1.4.0", + "wide", ] [[package]] @@ -4669,68 +5066,154 @@ dependencies = [ ] [[package]] -name = "phf_shared" +name = "phf" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "siphasher", + "phf_shared", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] [[package]] -name = "plotters" -version = "0.3.7" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", + "siphasher", ] [[package]] -name = "plotters-backend" -version = "0.3.7" +name = "pin-project" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] [[package]] -name = "plotters-svg" -version = "0.3.7" +name = "pin-project-internal" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ - "plotters-backend", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "pollster" -version = "0.4.0" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "portable-atomic-util" +name = "pliron" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f3650a6ccb88c14a2c493bc469c43d157093307cccd46bc8c1daf241d8b2e4" +dependencies = [ + "awint", + "combine", + "downcast-rs", + "dyn-clone", + "hi_sparse_bitset", + "inventory", + "linkme", + "log", + "pliron-derive", + "regex", + "rustc-hash 2.1.2", + "rustc_apfloat", + "slotmap", + "thiserror 2.0.18", + "utf8-chars", +] + +[[package]] +name = "pliron-derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c51af14f1d04076965ef02bd24394515433223a614b5279d63b3372f6d8a32" +dependencies = [ + "combine", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc-hash 2.1.2", + "syn 2.0.117", +] + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" @@ -4740,9 +5223,9 @@ dependencies = [ [[package]] name = "portgraph" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d2bc5f0a75d052384bd7f73a30b8e264041cebeeab29cfee0c6b72224bfd57f" +checksum = "6a1395575e261a0c0dd2d360ac4e3b14a4c9f68647ee12e59d96af959e4c0f79" dependencies = [ "bitvec", "delegate", @@ -4840,6 +5323,16 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -4967,9 +5460,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9326e3a0093d170582cf64ed9e4cf253b8aac155ec4a294ff62330450bbf094" +checksum = "38b92604caae1a1899b6a5b54967289dd538177c626004c91accf9d0ec7e4a12" dependencies = [ "cranelift-bitset", "log", @@ -4979,9 +5472,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c6433917e3789605b1f4cd2a589f637ff17212344e7fa5ba99544625ba52c7" +checksum = "5a7ac85c0bb3fb351f10d531230aaa5e366b46d7c4e5328e5f02801d6dac1165" dependencies = [ "proc-macro2", "quote", @@ -5256,21 +5749,21 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.9.4", + "rand 0.10.1", ] [[package]] name = "rand_pcg" -version = "0.9.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b48ac3f7ffaab7fac4d2376632268aa5f89abdb55f7ebf8f4d11fffccb2320f7" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5377,6 +5870,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "recasting" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70aeee1a07922bfe7c79e148553dee262248d2493d9778eee7551b9b5d7cc651" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5439,6 +5938,7 @@ dependencies = [ "hashbrown 0.17.1", "log", "rustc-hash 2.1.2", + "serde", "smallvec", ] @@ -5465,6 +5965,12 @@ dependencies = [ "regex-syntax 0.8.10", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.6.29" @@ -5554,7 +6060,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -5674,6 +6180,16 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_apfloat" +version = "0.2.3+llvm-462a31f5a5ab" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "486c2179b4796f65bfe2ee33679acf0927ac83ecf583ad6c91c3b4570911b9ad" +dependencies = [ + "bitflags 2.11.1", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -5771,6 +6287,63 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustpython-ast" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cdaf8ee5c1473b993b398c174641d3aa9da847af36e8d5eb8291930b72f31a5" +dependencies = [ + "is-macro", + "malachite-bigint", + "rustpython-parser-core", + "static_assertions", +] + +[[package]] +name = "rustpython-parser" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "868f724daac0caf9bd36d38caf45819905193a901e8f1c983345a68e18fb2abb" +dependencies = [ + "anyhow", + "is-macro", + "itertools 0.11.0", + "lalrpop-util 0.20.2", + "log", + "malachite-bigint", + "num-traits", + "phf", + "phf_codegen", + "rustc-hash 1.1.0", + "rustpython-ast", + "rustpython-parser-core", + "tiny-keccak", + "unic-emoji-char", + "unic-ucd-ident", + "unicode_names2", +] + +[[package]] +name = "rustpython-parser-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b6c12fa273825edc7bccd9a734f0ad5ba4b8a2f4da5ff7efe946f066d0f4ad" +dependencies = [ + "is-macro", + "memchr", + "rustpython-parser-vendored", +] + +[[package]] +name = "rustpython-parser-vendored" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04fcea49a4630a3a5d940f4d514dc4f575ed63c14c3e3ed07146634aed7f67a6" +dependencies = [ + "memchr", + "once_cell", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -5779,19 +6352,19 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustworkx-core" -version = "0.17.1" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaeee6f84153fd6f62507fc22bfe9499c8485075b44186dcbb918166ef75116f" +checksum = "4b213df2cbfa5f580ed2c0ac3619eda06692a4ff0211f0aebbb4008eaa9724a6" dependencies = [ "fixedbitset 0.5.7", "foldhash 0.1.5", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "indexmap 2.14.0", - "ndarray 0.16.1", + "ndarray 0.17.2", "num-traits", "petgraph 0.8.3", "priority-queue 2.7.0", - "rand 0.9.4", + "rand 0.10.1", "rand_distr", "rand_pcg", "rayon", @@ -5816,15 +6389,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - [[package]] name = "safe_arch" version = "1.0.0" @@ -5924,19 +6488,6 @@ dependencies = [ "libc", ] -[[package]] -name = "selene-core" -version = "0.2.1" -source = "git+https://github.com/Quantinuum/selene.git?rev=1794e8d1dba26120a18e904940c014f4e034bed6#1794e8d1dba26120a18e904940c014f4e034bed6" -dependencies = [ - "anyhow", - "delegate", - "derive_more 2.1.1", - "libloading 0.8.9", - "ouroboros", - "thiserror 2.0.18", -] - [[package]] name = "selene-core" version = "0.2.2" @@ -5956,7 +6507,7 @@ version = "0.2.6" source = "git+https://github.com/Quantinuum/selene.git?rev=01300ee5d4825e2dfc6500941d0540c3ff06988a#01300ee5d4825e2dfc6500941d0540c3ff06988a" dependencies = [ "anyhow", - "selene-core 0.2.2", + "selene-core", ] [[package]] @@ -5965,7 +6516,7 @@ version = "0.2.6" source = "git+https://github.com/Quantinuum/selene.git?rev=01300ee5d4825e2dfc6500941d0540c3ff06988a#01300ee5d4825e2dfc6500941d0540c3ff06988a" dependencies = [ "anyhow", - "selene-core 0.2.2", + "selene-core", ] [[package]] @@ -6043,6 +6594,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -6140,15 +6711,14 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simba" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +checksum = "8f45c644a9f3a386f9288625d9f0c1e999e1acf07a37df35d0516c7f199d9cb2" dependencies = [ "approx 0.5.1", "num-complex 0.4.6", "num-traits", - "paste", - "wide 0.7.33", + "wide", ] [[package]] @@ -6344,15 +6914,6 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - [[package]] name = "strum" version = "0.28.0" @@ -6375,18 +6936,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "strum_macros" version = "0.28.0" @@ -6417,6 +6966,27 @@ dependencies = [ "vec_box", ] +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "1.0.109" @@ -6515,6 +7085,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" @@ -6530,6 +7110,16 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6661,11 +7251,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tket" -version = "0.17.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7717a9c758bb7f2082d31bb0c021c20ebe816cbc479205e27d9d83bfd82ea4c" +checksum = "a56460bb4f82313dd11c188979a4c3f2eb74b98fb6ba2d60fef0d44e0eff8b4b" dependencies = [ "anyhow", + "ascent", "bytemuck", "cgmath", "chrono", @@ -6680,6 +7271,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "num-rational", + "pastey", "pest", "pest_derive", "petgraph 0.8.3", @@ -6690,7 +7282,8 @@ dependencies = [ "serde_json", "serde_with", "smol_str", - "strum 0.27.2", + "strum 0.28.0", + "thiserror 2.0.18", "tket-json-rs", "tracing", "typetag", @@ -6712,9 +7305,9 @@ dependencies = [ [[package]] name = "tket-qsystem" -version = "0.23.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d11f18e7cd1fd264a9e5c4492c92770de342524979e72de5854fa0555b2644e8" +checksum = "d9b3899fb263ffd2b0990a1afc7c97fe06fc3045fcc1cb61d39bf19bd69378ad" dependencies = [ "anyhow", "delegate", @@ -6726,7 +7319,7 @@ dependencies = [ "lazy_static", "serde", "smol_str", - "strum 0.27.2", + "strum 0.28.0", "tket", "tket-json-rs", "typetag", @@ -6740,12 +7333,24 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6756,6 +7361,31 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -6764,7 +7394,7 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned", + "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", @@ -6776,6 +7406,9 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] [[package]] name = "toml_datetime" @@ -6793,6 +7426,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow 0.7.15", @@ -6807,6 +7442,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.2", ] @@ -6831,6 +7467,20 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower" version = "0.5.3" @@ -6858,7 +7508,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "url", @@ -6870,6 +7520,40 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +[[package]] +name = "tower-lsp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" +dependencies = [ + "async-trait", + "auto_impl", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower 0.4.13", + "tower-lsp-macros", + "tracing", +] + +[[package]] +name = "tower-lsp-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -6907,6 +7591,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "triple_arena" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a99051ec05383f70bcab71884d39083a455257795e71cb780048a4bc890f3b8" +dependencies = [ + "recasting", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6919,6 +7612,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -6967,12 +7666,70 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-emoji-char" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b07221e68897210270a38bde4babb655869637af0f69407f96053a34f76494d" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -6997,6 +7754,28 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand 0.8.6", +] + [[package]] name = "unit-prefix" version = "0.5.2" @@ -7019,6 +7798,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -7027,6 +7807,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-chars" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92498c67ea3511b37eccff13b573ed871faf0ce9c4056ab032bd01a681f445a0" +dependencies = [ + "arrayvec 0.7.6", +] + [[package]] name = "utf8-width" version = "0.1.8" @@ -7194,22 +7983,22 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.246.2" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" dependencies = [ "leb128fmt", - "wasmparser 0.246.2", + "wasmparser 0.251.0", ] [[package]] name = "wasm-encoder" -version = "0.248.0" +version = "0.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" dependencies = [ "leb128fmt", - "wasmparser 0.248.0", + "wasmparser 0.252.0", ] [[package]] @@ -7238,12 +8027,12 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.246.2" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" dependencies = [ "bitflags 2.11.1", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indexmap 2.14.0", "semver", "serde", @@ -7251,9 +8040,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.248.0" +version = "0.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" dependencies = [ "bitflags 2.11.1", "indexmap 2.14.0", @@ -7262,32 +8051,33 @@ dependencies = [ [[package]] name = "wasmprinter" -version = "0.246.2" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e41f7493ba994b8a779430a4c25ff550fd5a40d291693af43a6ef48688f00e3" +checksum = "8798c1a699bd25648b6708eefe94d97c6f9891febb94b42cca1f7a4b086ea64e" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.246.2", + "wasmparser 0.251.0", ] [[package]] name = "wasmtime" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372db8bbad8ec962038101f75ab2c3ffcd18797d7d3ae877a58ab9873cd0c4bd" +checksum = "c4213d2f019a5e44aa8a61d8826dd33a505bff79f749b14a8bafd67321cb9351" dependencies = [ - "addr2line", + "addr2line 0.26.1", "async-trait", "bitflags 2.11.1", "bumpalo", "cc", "cfg-if", + "futures", "libc", "log", "mach2", "memfd", - "object", + "object 0.39.1", "once_cell", "postcard", "pulley-interpreter", @@ -7296,7 +8086,7 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasmparser 0.246.2", + "wasmparser 0.251.0", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-cranelift", @@ -7311,49 +8101,57 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e15aa0d1545e48d9b25ca604e9e27b4cd6d5886d30ac5787b57b3a2daf85b57" +checksum = "d45863de41977ec6453e859cf843d456fa3fcb45a659b66d16e794f90ec4f5b7" dependencies = [ "anyhow", "cpp_demangle", "cranelift-bforest", "cranelift-bitset", "cranelift-entity", - "gimli", - "hashbrown 0.16.1", + "gimli 0.33.0", + "hashbrown 0.17.1", "indexmap 2.14.0", "log", - "object", + "object 0.39.1", "postcard", "rustc-demangle", + "semver", "serde", "serde_derive", "sha2 0.10.9", "smallvec", "target-lexicon", - "wasm-encoder 0.246.2", - "wasmparser 0.246.2", + "wasm-encoder 0.251.0", + "wasmparser 0.251.0", "wasmprinter", + "wasmtime-internal-component-util", "wasmtime-internal-core", ] +[[package]] +name = "wasmtime-internal-component-util" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ad5abd5822a22dbf4014475cdfd1fe790707761cd732d74aaa3ba4d5ba489" + [[package]] name = "wasmtime-internal-core" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f2c7fa6523647262bfb4095dbdf4087accefe525813e783f81a0c682f418ce4" +checksum = "3fc28372e36eaf8cf70faa83b5779137f7e99c8d18569a125d1580e735cc9e4d" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", "libm", "serde", ] [[package]] name = "wasmtime-internal-cranelift" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98c032f422e39061dfc43f32190c0a3526b04161ec4867f362958f3fe9d1fe29" +checksum = "a433efc6e35112a5457e1dc8bc4d8d39820ac7722267e89bc04e5df641f32124" dependencies = [ "cfg-if", "cranelift-codegen", @@ -7361,15 +8159,15 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", + "gimli 0.33.0", "itertools 0.14.0", "log", - "object", + "object 0.39.1", "pulley-interpreter", "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.246.2", + "wasmparser 0.251.0", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-unwinder", @@ -7378,9 +8176,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8dd76d80adf450cc260ba58f23c28030401930b19149695b1d121f7d621e791" +checksum = "18a1d3a39d0d210f6b8574ee96a4315e0a14c67f3a1fc3cd5372cb10d2fb4422" dependencies = [ "cc", "cfg-if", @@ -7393,9 +8191,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab453cc600b28ee5d3f9495aa6d4cb2c81eda40903e9287296b548fba8b2391d" +checksum = "9f667288cb4dfa68a4639ffac4d5628535dda64ebdc2b990526efb12b30ba803" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -7403,9 +8201,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1859e920871515d324fb9757c3e448d6ed1512ca6ccdff14b6e016505d6ada" +checksum = "eba651d44ab0faad4c58106b3adb45068189fb65ef50f0c404b6d9e3bf81a357" dependencies = [ "cfg-if", "libc", @@ -7415,22 +8213,22 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dfe405bd6adb1386d935a30f16a236bd4ef0d3c383e7cbbab98d063c9d9b73" +checksum = "2ecc52563b0558af2a7487eb710de07cc4532564b55528876129238e83118cb1" dependencies = [ "cfg-if", "cranelift-codegen", "log", - "object", + "object 0.39.1", "wasmtime-environ", ] [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "44.0.1" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a9b9165fc45d42c81edfe3e9cb458e58720594ad5db6553c4079ea041a4a581" +checksum = "e747f4a074699ba1b4e4d841fb263f9b7df5bd1555181c4752bf5990d21ba676" dependencies = [ "proc-macro2", "quote", @@ -7439,22 +8237,22 @@ dependencies = [ [[package]] name = "wast" -version = "248.0.0" +version = "252.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" +checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width 0.2.2", - "wasm-encoder 0.248.0", + "wasm-encoder 0.252.0", ] [[package]] name = "wat" -version = "1.248.0" +version = "1.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" +checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" dependencies = [ "wast", ] @@ -7674,16 +8472,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch 0.7.4", -] - [[package]] name = "wide" version = "1.4.0" @@ -7691,7 +8479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7714cd0430a663154667c74da5d09325c2387695bee18b3f7f72825aa3693a" dependencies = [ "bytemuck", - "safe_arch 1.0.0", + "safe_arch", ] [[package]] @@ -7826,6 +8614,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -7853,6 +8650,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -7895,6 +8707,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7907,6 +8725,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7919,6 +8743,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7943,6 +8773,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7955,6 +8791,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7967,6 +8809,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7979,6 +8827,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8269,6 +9123,61 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" + +[[package]] +name = "zlup" +version = "0.1.0" +dependencies = [ + "clap 4.6.1", + "criterion 0.5.1", + "env_logger", + "insta", + "log", + "miette", + "once_cell", + "pest", + "pest_derive", + "pretty_assertions", + "proptest", + "serde", + "serde_json", + "smol_str", + "tempfile", + "thiserror 1.0.69", + "tket", + "tokio", + "toml 0.8.23", + "tower-lsp", +] + +[[package]] +name = "zluppy-python" +version = "0.2.0-dev.0" +dependencies = [ + "pyo3", + "serde_json", + "zlup", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 3001bac72..e659998e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ members = [ "crates/pecos*", "crates/benchmarks", "exp/pecos*", + "exp/zlup", + "exp/guppy-zlup", + "exp/zluppy", ] [workspace.package] @@ -41,7 +44,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" ron = "0.12" toml = "1" -toml_edit = "0.22" +toml_edit = "0.25" # --- CLI --- clap = { version = "4", features = ["derive"] } @@ -65,18 +68,14 @@ libc = "0.2" libloading = "0.9" # --- LLVM --- -inkwell = "0.8" +inkwell = { version = "0.9", features = ["llvm21-1-prefer-dynamic"] } # --- HUGR / tket --- -tket = { version = "0.17", default-features = false } -tket-qsystem = { version = "0.23", default-features = false } -# Pin hugr-core to 0.25.6: hugr-core 0.25.7 widened strum to >=0.27.2,<0.29 which lets the -# resolver pick strum 0.28, conflicting with tket 0.17 (requires strum ^0.27). -# Remove this pin once tket-qsystem releases a version compatible with tket 0.18. -hugr-core = "=0.25.6" +tket = { version = "0.19", default-features = false } +tket-qsystem = { version = "0.25", default-features = false } # --- WebAssembly --- -wasmtime = { version = "44", default-features = false, features = [ +wasmtime = { version = "46", default-features = false, features = [ "cranelift", "runtime", "wat", @@ -90,7 +89,7 @@ pest_derive = "2" regex = "1" # --- Numerical computing --- -nalgebra = "0.34" +nalgebra = "0.35" num = "0.4" num-complex = "0.4" num-traits = "0.2" @@ -110,7 +109,6 @@ bitflags = "2" bitvec = { version = "1", features = ["serde"] } bytemuck = { version = "1", features = ["derive"] } dyn-clone = "1" -itertools = "0.14" smallvec = "1" # --- Concurrency --- @@ -124,7 +122,7 @@ pollster = "0.4" # --- Graph algorithms --- petgraph = "0.8" -rustworkx-core = "0.17" +rustworkx-core = "0.18" # --- Networking & archive handling --- reqwest = { version = "0.13", default-features = false, features = [ @@ -137,7 +135,8 @@ tar = "0.4" xz2 = "0.1" bzip2 = "0.6" sevenz-rust = "0.6" -zip = { version = "2.4", default-features = false, features = ["deflate"] } +zip = { version = "8", default-features = false, features = ["deflate"] } +zstd = "0.13" # --- Logging --- log = "0.4" @@ -213,6 +212,7 @@ pecos-qis-ffi-types = { version = "0.2.0-dev.0", path = "crates/pecos-qis-ffi-ty pecos-quantum = { version = "0.2.0-dev.0", path = "crates/pecos-quantum" } pecos-random = { version = "0.2.0-dev.0", path = "crates/pecos-random" } pecos-relay-bp = { version = "0.2.0-dev.0", path = "crates/pecos-relay-bp" } +pecos-results = { version = "0.2.0-dev.0", path = "crates/pecos-results" } pecos-rslib = { version = "0.2.0-dev.0", path = "python/pecos-rslib" } pecos-rslib-llvm = { version = "0.2.0-dev.0", path = "python/pecos-rslib-llvm" } pecos-simulators = { version = "0.2.0-dev.0", path = "crates/pecos-simulators" } @@ -266,3 +266,26 @@ multiple-crate-versions = "allow" similar-names = "allow" many-single-char-names = "allow" too-many-lines = "allow" # ~114 hits across 11 crates -- sim algorithms, tests, benchmarks + +# Simulation-heavy crates run at opt-level 2 even in dev/test builds: +# statistical validation tests (e.g. the surface-code LER equivalence +# gate) need tens of thousands of simulated shots and are unusably slow +# unoptimized (>10 min vs ~2 min). Debug assertions remain enabled. +[profile.dev.package.pecos-core] +opt-level = 2 +[profile.dev.package.pecos-random] +opt-level = 2 +[profile.dev.package.pecos-simulators] +opt-level = 2 +[profile.dev.package.pecos-engines] +opt-level = 2 +[profile.dev.package.pecos-neo] +opt-level = 2 +[profile.dev.package.pecos-qasm] +opt-level = 2 +[profile.dev.package.pecos-qec] +opt-level = 2 +[profile.dev.package.pecos-fusion-blossom] +opt-level = 2 +[profile.dev.package.fusion-blossom] +opt-level = 2 diff --git a/Justfile b/Justfile index 32f0ff781..cae5c997e 100644 --- a/Justfile +++ b/Justfile @@ -11,9 +11,11 @@ default: @echo "Quick start:" @echo " just install-cli # Optional: install pecos CLI for direct use" @echo " just setup # First time: detect and install dependencies" + @echo " just setup-env # Configure LLVM 21.1 for local development" + @echo " just dev-preflight # Check LLVM before the longer dev workflow" @echo " just build # Build PECOS (runs setup if needed)" @echo " just test # Run all tests" - @echo " just dev # Build + test (daily workflow)" + @echo " just dev # Build + test (runs dev-preflight first)" @echo " just lint # Check formatting and linting" @echo " just security-check # Check dependency/security policy" @echo " just doctor # Diagnose environment problems" @@ -53,22 +55,15 @@ setup: _msvc-bootstrap setup-ci: _msvc-bootstrap {{pecos}} setup --yes +# Install/configure local LLVM 21.1 build environment +[group('setup')] +setup-env: ensure-local-build-env + @echo "Development environment is configured. Run: just doctor" + # Ensure CI has a runtime-valid LLVM and export PECOS build env files [group('setup')] -ci-env: _msvc-bootstrap - #!/usr/bin/env bash - set -euo pipefail - export CARGO_NET_RETRY=10 - for attempt in 1 2 3; do - if {{pecos}} llvm ensure --managed --no-configure && {{pecos}} env --github-actions; then - exit 0 - fi - if [ "$attempt" -eq 3 ]; then - exit 1 - fi - echo "ci-env failed on attempt $attempt; retrying..." - sleep 5 - done +ci-env: ensure-ci-build-env + {{pecos}} env --github-actions # Check development environment for common problems [group('setup')] @@ -79,17 +74,18 @@ doctor: _msvc-bootstrap ok() { echo " [OK] $1: $2"; } fail() { echo " [!!] $1: $2"; PROBLEMS=$((PROBLEMS + 1)); } - echo "LLVM 14:" + echo "LLVM 21.1:" if LLVM_DIR=$({{pecos}} llvm find 2>/dev/null); then VERSION=$("$LLVM_DIR/bin/llvm-config" --version 2>/dev/null || {{pecos}} llvm version 2>/dev/null | head -1 || echo "unknown") - ok "installed" "$VERSION at $LLVM_DIR" + LINK_MODE=$("$LLVM_DIR/bin/llvm-config" --shared-mode 2>/dev/null || echo "unknown") + ok "installed" "$VERSION ($LINK_MODE LLVM) at $LLVM_DIR" else fail "installed" "not found (run: just setup)" fi - if [ -f .cargo/config.toml ] && grep -q "LLVM_SYS_140_PREFIX" .cargo/config.toml 2>/dev/null; then - ok ".cargo/config.toml" "LLVM_SYS_140_PREFIX configured" + if [ -f .cargo/config.toml ] && grep -q "LLVM_SYS_211_PREFIX" .cargo/config.toml 2>/dev/null; then + ok ".cargo/config.toml" "LLVM_SYS_211_PREFIX configured" else - fail ".cargo/config.toml" "LLVM_SYS_140_PREFIX not set (run: pecos llvm configure)" + fail ".cargo/config.toml" "LLVM_SYS_211_PREFIX not set (run: pecos llvm configure)" fi echo "" @@ -438,9 +434,51 @@ bench profile="release" features="" pattern="": _msvc-bootstrap (validate-bench- # Dev Workflows # ============================================================================= +# Check local prerequisites before the longer dev workflow +[group('dev')] +dev-preflight: _msvc-bootstrap + #!/usr/bin/env bash + set -euo pipefail + print_llvm_hint() { + echo "Run: just setup-env" + echo "Or configure your own LLVM 21.1 install:" + echo " cargo run --locked -p pecos-cli -- llvm configure /path/to/llvm" + } + + if ! LLVM_CHECK_OUTPUT=$({{pecos}} llvm check 2>&1); then + echo "$LLVM_CHECK_OUTPUT" + echo "" + echo "PECOS dev preflight failed: LLVM 21.1 is not ready." + print_llvm_hint + exit 1 + fi + + if [ ! -f .cargo/config.toml ] || ! grep -q "LLVM_SYS_211_PREFIX" .cargo/config.toml 2>/dev/null; then + echo "PECOS dev preflight failed: .cargo/config.toml does not set LLVM_SYS_211_PREFIX." + print_llvm_hint + exit 1 + fi + + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*|Windows*) ;; + *) + LLVM_DIR=$({{pecos}} llvm find 2>/dev/null || true) + if [ -n "$LLVM_DIR" ] && [ -x "$LLVM_DIR/bin/llvm-config" ]; then + LINK_MODE=$("$LLVM_DIR/bin/llvm-config" --shared-mode 2>/dev/null || echo "unknown") + if [ "$LINK_MODE" != "shared" ]; then + echo "PECOS dev preflight failed: LLVM at $LLVM_DIR reports '$LINK_MODE' link mode." + echo "Full workspace HUGR tests need shared LLVM 21.1 to avoid high-memory static links." + print_llvm_hint + exit 1 + fi + fi + ;; + esac + echo "PECOS dev preflight passed." + # Fast dev cycle: build + test only (lang: all, rust, python, julia, go) [group('dev')] -dev lang="all": (validate-dev-lang lang) +dev lang="all": (validate-dev-lang lang) dev-preflight #!/usr/bin/env bash set -euo pipefail DEV_LANG="{{lang}}" @@ -527,7 +565,7 @@ docs-test: # Deps Management (prefer `just setup` or `pecos install `) # ============================================================================= -# Install LLVM 14 +# Install PECOS-managed LLVM 21.1 where supported [group('deps')] install-llvm: _msvc-bootstrap {{pecos}} install llvm @@ -542,7 +580,7 @@ install-cuda: _msvc-bootstrap configure-llvm: _msvc-bootstrap {{pecos}} llvm configure -# Check LLVM 14 installation status +# Check LLVM 21.1 installation status [group('deps')] check-llvm: _msvc-bootstrap -{{pecos}} llvm check @@ -707,10 +745,9 @@ go-lint profile="release": (validate-profile "go-lint" profile) (go-build profil pytest-perf: build-release uv run --frozen --group numpy-compat pytest python/pecos-rslib/tests -m "performance" -v -# Run tests for optional dependencies +# Run tests for optional dependencies (only quantum-pecos carries the marker) [group('test')] pytest-dep: - uv run --frozen pytest python/pecos-rslib/tests -m "optional_dependency" uv run --frozen pytest python/quantum-pecos/tests -m "optional_dependency" # Run the slower integration lane (excluded from the default fast lane) @@ -854,6 +891,62 @@ setup-quiet: set -euo pipefail {{pecos}} setup --quiet +[private] +ensure-local-build-env: _msvc-bootstrap + #!/usr/bin/env bash + set -euo pipefail + + has_llvm_config() { + [ -f .cargo/config.toml ] && grep -q "LLVM_SYS_211_PREFIX" .cargo/config.toml 2>/dev/null + } + + if {{pecos}} llvm check >/dev/null 2>&1 && has_llvm_config; then + exit 0 + fi + + if {{pecos}} llvm find >/dev/null 2>&1; then + {{pecos}} llvm configure + exit 0 + fi + + just install-build-llvm + +[private] +ensure-ci-build-env: install-build-llvm + +[private] +install-build-llvm: _msvc-bootstrap + #!/usr/bin/env bash + set -euo pipefail + LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" + case "${RUNNER_OS:-$(uname -s)}" in + Linux) + {{pecos}} llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-conda-linux.sh + {{pecos}} llvm configure + ;; + macOS|Darwin) + if ! command -v brew >/dev/null 2>&1; then + echo "PECOS-managed LLVM is not available on macOS yet." >&2 + echo "Install Homebrew LLVM 21 or configure your own shared LLVM 21.1:" >&2 + echo " brew install llvm@21" >&2 + echo " cargo run --locked -p pecos-cli -- llvm configure /path/to/llvm" >&2 + exit 1 + fi + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + {{pecos}} llvm configure "$(brew --prefix llvm@21)" + ;; + Windows*|MINGW*|MSYS*|CYGWIN*) + LLVM_ENV_ROOT="${USERPROFILE:-$HOME}\\.pecos\\deps\\llvm-21.1" + LLVM_PREFIX="${LLVM_ENV_ROOT}\\Library" + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/ci/install-llvm-21-windows.ps1 -InstallDir "$LLVM_ENV_ROOT" -Version "$LLVM_RELEASE_VERSION" + {{pecos}} llvm configure "$LLVM_PREFIX" + ;; + *) + {{pecos}} llvm ensure --managed --no-configure + {{pecos}} llvm configure + ;; + esac + # Sync Python deps (fast if already installed, skips maturin rebuilds) [private] sync-deps: diff --git a/crates/pecos-build/Cargo.toml b/crates/pecos-build/Cargo.toml index 66e0c692c..2164401fa 100644 --- a/crates/pecos-build/Cargo.toml +++ b/crates/pecos-build/Cargo.toml @@ -31,6 +31,7 @@ bzip2.workspace = true xz2.workspace = true sevenz-rust.workspace = true zip.workspace = true +zstd.workspace = true # Error handling thiserror.workspace = true diff --git a/crates/pecos-build/README.md b/crates/pecos-build/README.md index 169a2a2af..96a70c7eb 100644 --- a/crates/pecos-build/README.md +++ b/crates/pecos-build/README.md @@ -8,9 +8,9 @@ Used by build scripts (`build.rs`) to manage external dependencies. Handles down ## Key Features -- **LLVM 14 management**: Install, configure, and find LLVM 14 +- **LLVM 21.1 management**: Install where PECOS can provide shared LLVM, configure, and find LLVM 21.1 - **Dependency downloads**: QuEST, Qulacs, Stim, Eigen, etc. -- **Tool finding**: `find_tool("llvm-as")`, `find_llvm_14()` +- **Tool finding**: `find_tool("llvm-as")`, `find_llvm()` - **Manifest parsing**: Load `pecos.toml` for dependency versions ## PECOS Home Directory @@ -20,8 +20,7 @@ All dependencies managed under `~/.pecos/`: ``` ~/.pecos/ ├── cache/ # Downloaded archives -├── deps/ # Extracted source trees -├── llvm/ # LLVM installation +├── deps/ # Extracted toolchains and source trees, including llvm-21.1/ └── tmp/ # Temporary files ``` diff --git a/crates/pecos-build/src/cargo_config.rs b/crates/pecos-build/src/cargo_config.rs index 100d0ccf5..5a1328b65 100644 --- a/crates/pecos-build/src/cargo_config.rs +++ b/crates/pecos-build/src/cargo_config.rs @@ -133,11 +133,11 @@ mod tests { fn creates_forced_env_in_empty_project() { let tmp = tempfile::tempdir().unwrap(); let mut cfg = CargoConfig::open(tmp.path()).unwrap(); - cfg.set_env("LLVM_SYS_140_PREFIX", "C:/llvm", true).unwrap(); + cfg.set_env("LLVM_SYS_211_PREFIX", "C:/llvm", true).unwrap(); assert!(cfg.save().unwrap()); let parsed: toml::Value = toml::from_str(&read(tmp.path())).unwrap(); - let env = &parsed["env"]["LLVM_SYS_140_PREFIX"]; + let env = &parsed["env"]["LLVM_SYS_211_PREFIX"]; assert_eq!(env["value"].as_str().unwrap(), "C:/llvm"); assert!(env["force"].as_bool().unwrap()); } @@ -162,7 +162,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); // First writer: LLVM. let mut a = CargoConfig::open(tmp.path()).unwrap(); - a.set_env("LLVM_SYS_140_PREFIX", "/llvm", true).unwrap(); + a.set_env("LLVM_SYS_211_PREFIX", "/llvm", true).unwrap(); a.save().unwrap(); // Second writer: cuQuantum -- must merge into the same [env]. let mut b = CargoConfig::open(tmp.path()).unwrap(); @@ -174,7 +174,7 @@ mod tests { // Both keys survive and parse. let parsed: toml::Value = toml::from_str(&text).unwrap(); assert_eq!( - parsed["env"]["LLVM_SYS_140_PREFIX"]["value"], + parsed["env"]["LLVM_SYS_211_PREFIX"]["value"], "/llvm".into() ); assert_eq!(parsed["env"]["CUQUANTUM_ROOT"]["value"], "/cq".into()); @@ -192,14 +192,14 @@ mod tests { .unwrap(); let mut cfg = CargoConfig::open(tmp.path()).unwrap(); - cfg.set_env("LLVM_SYS_140_PREFIX", "/llvm", true).unwrap(); + cfg.set_env("LLVM_SYS_211_PREFIX", "/llvm", true).unwrap(); cfg.save().unwrap(); let parsed: toml::Value = toml::from_str(&read(tmp.path())).unwrap(); assert_eq!(parsed["build"]["jobs"].as_integer().unwrap(), 4); assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar"); assert_eq!( - parsed["env"]["LLVM_SYS_140_PREFIX"]["value"], + parsed["env"]["LLVM_SYS_211_PREFIX"]["value"], "/llvm".into() ); } @@ -231,11 +231,11 @@ mod tests { fn save_is_idempotent_no_rewrite_when_unchanged() { let tmp = tempfile::tempdir().unwrap(); let mut cfg = CargoConfig::open(tmp.path()).unwrap(); - cfg.set_env("LLVM_SYS_140_PREFIX", "/llvm", true).unwrap(); + cfg.set_env("LLVM_SYS_211_PREFIX", "/llvm", true).unwrap(); assert!(cfg.save().unwrap(), "first write should change the file"); let mut again = CargoConfig::open(tmp.path()).unwrap(); - again.set_env("LLVM_SYS_140_PREFIX", "/llvm", true).unwrap(); + again.set_env("LLVM_SYS_211_PREFIX", "/llvm", true).unwrap(); assert!( !again.save().unwrap(), "re-applying the same value must not rewrite the file" diff --git a/crates/pecos-build/src/home.rs b/crates/pecos-build/src/home.rs index dd7cc4e1e..4b68a649e 100644 --- a/crates/pecos-build/src/home.rs +++ b/crates/pecos-build/src/home.rs @@ -6,7 +6,7 @@ //! ~/.pecos/ //! ├── cache/ # Downloaded archives (tar.gz, 7z, etc.) //! ├── deps/ # All dependencies, versioned by name -//! │ ├── llvm-14/ +//! │ ├── llvm-21.1/ //! │ ├── cuda-12.6.3/ //! │ ├── quest-v4.2.0/ //! │ ├── stim-bd60b73525fd/ @@ -159,8 +159,8 @@ pub fn resolve_dep_path(name: &str, version: &str) -> Result { Ok(versioned) } -/// LLVM major version used by PECOS -pub const LLVM_VERSION: &str = "14"; +/// LLVM version used by PECOS +pub const LLVM_VERSION: &str = crate::llvm::REQUIRED_VERSION; /// Get the vendored cmake installation directory path (without creating it) /// @@ -339,7 +339,7 @@ pub fn print_legacy_warning(name: &str, old_path: &Path) { /// Description of a single legacy dep that can be migrated. pub struct LegacyDep { - /// Human-readable name (e.g. "LLVM 14") + /// Human-readable name (e.g. "LLVM 21.1") pub name: &'static str, /// Old path pub old: PathBuf, @@ -347,6 +347,25 @@ pub struct LegacyDep { pub new: PathBuf, } +/// Description of a legacy dep that cannot be migrated safely. +pub struct IncompatibleLegacyDep { + /// Human-readable name (e.g. "LLVM") + pub name: &'static str, + /// Legacy path + pub old: PathBuf, + /// Why the dependency cannot be migrated + pub reason: String, +} + +/// Result of scanning legacy dependency paths. +#[derive(Default)] +pub struct LegacyDepStatus { + /// Legacy dependencies that can be moved to versioned paths. + pub migratable: Vec, + /// Legacy dependencies that need user action instead of migration. + pub incompatible: Vec, +} + /// Check for legacy top-level installs that should be migrated. /// /// Returns a list of deps whose old path exists but new path does not. @@ -355,9 +374,22 @@ pub struct LegacyDep { /// /// Returns an error if unable to determine paths. pub fn find_legacy_deps() -> Result> { - let mut found = Vec::new(); + Ok(find_legacy_dep_status()?.migratable) +} + +/// Check legacy installs and report both migratable and incompatible entries. +/// +/// # Errors +/// +/// Returns an error if unable to determine paths. +pub fn find_legacy_dep_status() -> Result { let deps_dir = get_deps_dir_path()?; + let home = get_pecos_home_path()?; + Ok(find_legacy_dep_status_at(&home, &deps_dir)) +} +fn find_legacy_dep_status_at(home: &Path, deps_dir: &Path) -> LegacyDepStatus { + let mut status = LegacyDepStatus::default(); let checks: &[(&str, &str)] = &[ ("LLVM", LLVM_VERSION), ("CUDA", crate::cuda::CUDA_VERSION), @@ -367,33 +399,64 @@ pub fn find_legacy_deps() -> Result> { for &(name, version) in checks { let lower = name.to_lowercase(); let versioned = deps_dir.join(format!("{lower}-{version}")); - if versioned.exists() { - continue; // Already at versioned path - } + let versioned_exists = versioned.exists(); + let mut migration_queued = false; // Check unversioned deps/ path (e.g. deps/llvm/) let unversioned = deps_dir.join(&lower); if unversioned.exists() { - found.push(LegacyDep { - name, - old: unversioned, - new: versioned.clone(), - }); - continue; + if let Some(reason) = legacy_incompatibility_reason(name, &unversioned) { + status.incompatible.push(IncompatibleLegacyDep { + name, + old: unversioned, + reason, + }); + } else if !versioned_exists { + status.migratable.push(LegacyDep { + name, + old: unversioned, + new: versioned.clone(), + }); + migration_queued = true; + } } // Check top-level legacy path (e.g. ~/.pecos/llvm/) - if let Ok(top_level) = get_pecos_home_path().map(|h| h.join(&lower)) - && top_level.exists() - { - found.push(LegacyDep { - name, - old: top_level, - new: versioned, - }); + let top_level = home.join(&lower); + if top_level.exists() { + if let Some(reason) = legacy_incompatibility_reason(name, &top_level) { + status.incompatible.push(IncompatibleLegacyDep { + name, + old: top_level, + reason, + }); + continue; + } + if !versioned_exists && !migration_queued { + status.migratable.push(LegacyDep { + name, + old: top_level, + new: versioned, + }); + } } } - Ok(found) + status +} + +fn legacy_incompatibility_reason(name: &str, old_path: &Path) -> Option { + if name != "LLVM" { + return None; + } + + if crate::llvm::is_valid_llvm(old_path) { + None + } else { + Some(format!( + "not a valid LLVM {} installation; it may be an older LLVM 14 install", + crate::llvm::REQUIRED_VERSION + )) + } } /// Migrate a single legacy dep by renaming old -> new. @@ -409,6 +472,16 @@ pub fn migrate_legacy_dep(dep: &LegacyDep) -> Result<()> { Ok(()) } +/// Remove an incompatible legacy dependency path. +/// +/// # Errors +/// +/// Returns an error if the directory cannot be removed. +pub fn remove_incompatible_legacy_dep(dep: &IncompatibleLegacyDep) -> Result<()> { + fs::remove_dir_all(&dep.old)?; + Ok(()) +} + /// Get information about the PECOS home directory #[derive(Debug)] pub struct HomeInfo { @@ -469,6 +542,26 @@ mod tests { std::env::temp_dir().join(format!("pecos_test_{prefix}_{pid}_{id}")) } + #[cfg(unix)] + fn create_fake_llvm_config(llvm_dir: &Path, version: &str) { + use std::os::unix::fs::PermissionsExt; + + let bin_dir = llvm_dir.join("bin"); + fs::create_dir_all(&bin_dir).expect("Should create fake llvm bin dir"); + let llvm_config = bin_dir.join("llvm-config"); + fs::write( + &llvm_config, + format!("#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"{version}\"; fi\n"), + ) + .expect("Should write fake llvm-config"); + let mut permissions = fs::metadata(&llvm_config) + .expect("Should stat fake llvm-config") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&llvm_config, permissions) + .expect("Should make fake llvm-config executable"); + } + #[test] fn test_get_pecos_home_default() { // Test that default home ends with .pecos (uses real home dir) @@ -544,4 +637,97 @@ mod tests { // Cleanup let _ = std::fs::remove_dir_all(&test_home); } + + #[test] + fn legacy_migration_skips_invalid_llvm() { + let test_home = unique_test_dir("legacy_invalid_llvm"); + let deps = test_home.join("deps"); + let legacy_llvm = test_home.join("llvm"); + fs::create_dir_all(&legacy_llvm).expect("Should create legacy llvm dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert!( + status.migratable.iter().all(|dep| dep.name != "LLVM"), + "invalid legacy LLVM must not migrate into llvm-21.1" + ); + assert_eq!(status.incompatible.len(), 1); + assert_eq!(status.incompatible[0].name, "LLVM"); + assert_eq!(status.incompatible[0].old, legacy_llvm); + + let _ = std::fs::remove_dir_all(&test_home); + } + + #[test] + fn legacy_migration_reports_invalid_unversioned_llvm() { + let test_home = unique_test_dir("legacy_invalid_unversioned_llvm"); + let deps = test_home.join("deps"); + let legacy_llvm = deps.join("llvm"); + fs::create_dir_all(&legacy_llvm).expect("Should create legacy llvm dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert!( + status.migratable.iter().all(|dep| dep.name != "LLVM"), + "invalid unversioned LLVM must not migrate into llvm-21.1" + ); + assert_eq!(status.incompatible.len(), 1); + assert_eq!(status.incompatible[0].name, "LLVM"); + assert_eq!(status.incompatible[0].old, legacy_llvm); + + let _ = std::fs::remove_dir_all(&test_home); + } + + #[test] + fn legacy_migration_reports_invalid_llvm_when_versioned_path_exists() { + let test_home = unique_test_dir("legacy_invalid_llvm_with_current"); + let deps = test_home.join("deps"); + let legacy_llvm = test_home.join("llvm"); + let versioned_llvm = deps.join(format!("llvm-{LLVM_VERSION}")); + fs::create_dir_all(&legacy_llvm).expect("Should create legacy llvm dir"); + fs::create_dir_all(&versioned_llvm).expect("Should create versioned llvm dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert!(status.migratable.is_empty()); + assert_eq!(status.incompatible.len(), 1); + assert_eq!(status.incompatible[0].name, "LLVM"); + assert_eq!(status.incompatible[0].old, legacy_llvm); + + let _ = std::fs::remove_dir_all(&test_home); + } + + #[cfg(unix)] + #[test] + fn legacy_migration_reports_top_level_invalid_llvm_when_unversioned_can_migrate() { + let test_home = unique_test_dir("legacy_invalid_top_level_with_unversioned"); + let deps = test_home.join("deps"); + let unversioned_llvm = deps.join("llvm"); + let top_level_llvm = test_home.join("llvm"); + create_fake_llvm_config(&unversioned_llvm, crate::llvm::REQUIRED_VERSION); + fs::create_dir_all(&top_level_llvm).expect("Should create top-level llvm dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert_eq!(status.migratable.len(), 1); + assert_eq!(status.migratable[0].name, "LLVM"); + assert_eq!(status.migratable[0].old, unversioned_llvm); + assert_eq!(status.incompatible.len(), 1); + assert_eq!(status.incompatible[0].name, "LLVM"); + assert_eq!(status.incompatible[0].old, top_level_llvm); + + let _ = std::fs::remove_dir_all(&test_home); + } + + #[test] + fn legacy_migration_still_finds_non_llvm_deps() { + let test_home = unique_test_dir("legacy_cuda"); + let deps = test_home.join("deps"); + let legacy_cuda = test_home.join("cuda"); + fs::create_dir_all(&legacy_cuda).expect("Should create legacy cuda dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert_eq!(status.migratable.len(), 1); + assert_eq!(status.incompatible.len(), 0); + assert_eq!(status.migratable[0].name, "CUDA"); + assert_eq!(status.migratable[0].old, legacy_cuda); + + let _ = std::fs::remove_dir_all(&test_home); + } } diff --git a/crates/pecos-build/src/lib.rs b/crates/pecos-build/src/lib.rs index f6c9a9a2c..09f21d22d 100644 --- a/crates/pecos-build/src/lib.rs +++ b/crates/pecos-build/src/lib.rs @@ -3,7 +3,7 @@ //! This crate provides build script utilities for managing external dependencies: //! //! - Downloading and extracting C++ libraries (`QuEST`, Qulacs, Stim, etc.) -//! - Managing LLVM 14 installation +//! - Managing LLVM 21.1 installation //! - Managing the `~/.pecos/` home directory //! //! # PECOS Home Directory diff --git a/crates/pecos-build/src/llvm.rs b/crates/pecos-build/src/llvm.rs index 572476012..8f6ed03cd 100644 --- a/crates/pecos-build/src/llvm.rs +++ b/crates/pecos-build/src/llvm.rs @@ -1,6 +1,6 @@ //! LLVM detection and management //! -//! This module provides functionality to locate, install, and configure LLVM 14 +//! This module provides functionality to locate, install, and configure LLVM 21.1 //! for PECOS across different platforms. pub mod config; @@ -30,28 +30,76 @@ pub fn get_pecos_command() -> &'static str { "cargo run -p pecos --" } -/// LLVM version required by PECOS -pub const REQUIRED_VERSION: &str = "14"; +/// LLVM version required by PECOS. +pub const REQUIRED_VERSION: &str = "21.1"; -/// Find LLVM 14 installation on the system. +/// Cargo/llvm-sys environment variable for the required LLVM version. +pub const LLVM_SYS_PREFIX_ENV: &str = "LLVM_SYS_211_PREFIX"; + +/// Convert a path into a stable string for build environment variables and +/// Cargo config values. /// -/// This function searches for LLVM 14 in the following priority order: -/// 1. PECOS deps directory: `~/.pecos/deps/llvm/` +/// Windows `canonicalize()` returns verbatim paths such as `\\?\C:\...`. +/// Most Rust and Windows APIs accept those, but bindgen's libclang loader does +/// not treat them as valid DLL search directories. Cargo config also does not +/// need the verbatim prefix, so strip it while keeping the path absolute. +#[must_use] +pub fn path_to_env_string(path: &Path) -> String { + normalize_path_string(&path.to_string_lossy()) +} + +/// Normalize a stored path string before turning it back into a [`PathBuf`]. +#[must_use] +pub fn normalize_path_string(path: &str) -> String { + let path = path.replace('\\', "/"); + + if let Some(rest) = path.strip_prefix("//?/UNC/") { + return format!("//{rest}"); + } + + if let Some(rest) = path.strip_prefix("//?/") + && is_windows_drive_path(rest) + { + return rest.to_string(); + } + + path +} + +fn is_windows_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' +} + +/// Return whether an `llvm-config --version` string is compatible with PECOS. +#[must_use] +pub fn is_required_llvm_version(version: &str) -> bool { + let version = version.trim(); + version == REQUIRED_VERSION + || version + .strip_prefix(REQUIRED_VERSION) + .is_some_and(|rest| rest.starts_with('.')) +} + +/// Find a compatible LLVM installation on the system. +/// +/// This function searches for LLVM in the following priority order: +/// 1. PECOS deps directory: `~/.pecos/deps/llvm-{version}/` /// 2. Legacy PECOS path: `~/.pecos/llvm/` (prints deprecation warning) -/// - Windows also checks: `~/.pecos/LLVM-14` +/// - Windows also checks: `~/.pecos/LLVM-{version}` /// 3. Project-local installation (`llvm/` directory relative to repository root) /// 4. System installations (platform-specific locations) /// /// # Returns -/// - `Some(PathBuf)` if LLVM 14 is found and valid -/// - `None` if LLVM 14 is not found +/// - `Some(PathBuf)` if a compatible LLVM is found and valid +/// - `None` if a compatible LLVM is not found #[must_use] -pub fn find_llvm_14(repo_root: Option) -> Option { - // 1. Check new deps path: ~/.pecos/deps/llvm/ +pub fn find_llvm(repo_root: Option) -> Option { + // 1. Check versioned deps path: ~/.pecos/deps/llvm-{version}/ if let Ok(deps_llvm) = crate::home::get_llvm_dir_path() - && is_valid_llvm_14(&deps_llvm) + && let Some(llvm_prefix) = valid_llvm_prefix(&deps_llvm) { - return Some(deps_llvm); + return Some(llvm_prefix); } // 2. Check legacy top-level path: ~/.pecos/llvm/ @@ -60,49 +108,81 @@ pub fn find_llvm_14(repo_root: Option) -> Option { #[cfg(target_os = "windows")] { - let user_llvm_new = pecos_dir.join("LLVM-14"); - if is_valid_llvm_14(&user_llvm_new) { - crate::home::print_legacy_warning("LLVM", &user_llvm_new); - return Some(user_llvm_new); + let user_llvm_new = pecos_dir.join(format!("LLVM-{REQUIRED_VERSION}")); + if let Some(llvm_prefix) = valid_llvm_prefix(&user_llvm_new) { + crate::home::print_legacy_warning("LLVM", &llvm_prefix); + return Some(llvm_prefix); } } let user_llvm_legacy = pecos_dir.join("llvm"); - if is_valid_llvm_14(&user_llvm_legacy) { - crate::home::print_legacy_warning("LLVM", &user_llvm_legacy); - return Some(user_llvm_legacy); + if let Some(llvm_prefix) = valid_llvm_prefix(&user_llvm_legacy) { + crate::home::print_legacy_warning("LLVM", &llvm_prefix); + return Some(llvm_prefix); } } // 3. Check for project-local LLVM if let Some(root) = repo_root { let local_llvm = root.join("llvm"); - if is_valid_llvm_14(&local_llvm) { - return Some(local_llvm); + if let Some(llvm_prefix) = valid_llvm_prefix(&local_llvm) { + return Some(llvm_prefix); } } // 4. Check system installations - find_system_llvm_14() + find_system_llvm() +} + +fn valid_llvm_prefix(path: &Path) -> Option { + if is_valid_llvm(path) { + return Some(path.to_path_buf()); + } + + #[cfg(target_os = "windows")] + { + let conda_library_prefix = path.join("Library"); + if is_valid_llvm(&conda_library_prefix) { + return Some(conda_library_prefix); + } + } + + None } -/// Find LLVM 14 in system-wide locations (platform-specific) -fn find_system_llvm_14() -> Option { +/// Find the LLVM installation Cargo should use for this project. +/// +/// An explicit `.cargo/config.toml` setting takes priority because `cargo` +/// applies it to build scripts. If no valid project config exists, this falls +/// back to the normal managed/system detection order. +#[must_use] +pub fn find_configured_or_detected_llvm(repo_root: Option) -> Option { + if let Some(configured_path) = config::read_configured_llvm_path() + && is_valid_llvm(&configured_path) + { + return Some(configured_path); + } + + find_llvm(repo_root) +} + +/// Find LLVM in system-wide locations (platform-specific) +fn find_system_llvm() -> Option { #[cfg(target_os = "macos")] { - if let Ok(output) = Command::new("brew").args(["--prefix", "llvm@14"]).output() + if let Ok(output) = Command::new("brew").args(["--prefix", "llvm@21"]).output() && output.status.success() { let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); let path = PathBuf::from(path_str); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { return Some(path); } } - for path_str in ["/opt/homebrew/opt/llvm@14", "/usr/local/opt/llvm@14"] { + for path_str in ["/opt/homebrew/opt/llvm@21", "/usr/local/opt/llvm@21"] { let llvm_path = PathBuf::from(path_str); - if is_valid_llvm_14(&llvm_path) { + if is_valid_llvm(&llvm_path) { return Some(llvm_path); } } @@ -110,23 +190,23 @@ fn find_system_llvm_14() -> Option { #[cfg(target_os = "linux")] { - if let Ok(output) = Command::new("llvm-config-14").arg("--prefix").output() + if let Ok(output) = Command::new("llvm-config-21").arg("--prefix").output() && output.status.success() { let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); let path = PathBuf::from(path_str); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { return Some(path); } } for path_str in [ - "/usr/lib/llvm-14", - "/usr/local/llvm-14", - "/usr/lib/x86_64-linux-gnu/llvm-14", + "/usr/lib/llvm-21", + "/usr/local/llvm-21", + "/usr/lib/x86_64-linux-gnu/llvm-21", ] { let llvm_path = PathBuf::from(path_str); - if is_valid_llvm_14(&llvm_path) { + if is_valid_llvm(&llvm_path) { return Some(llvm_path); } } @@ -137,11 +217,11 @@ fn find_system_llvm_14() -> Option { for path_str in [ "C:\\Program Files\\LLVM", "C:\\LLVM", - "C:\\Program Files\\LLVM-14", - "C:\\LLVM-14", + "C:\\Program Files\\LLVM-21", + "C:\\LLVM-21", ] { let llvm_path = PathBuf::from(path_str); - if is_valid_llvm_14(&llvm_path) { + if is_valid_llvm(&llvm_path) { return Some(llvm_path); } } @@ -150,9 +230,9 @@ fn find_system_llvm_14() -> Option { None } -/// Check if a given path contains a valid LLVM 14 installation +/// Check if a given path contains a compatible LLVM installation #[must_use] -pub fn is_valid_llvm_14(path: &Path) -> bool { +pub fn is_valid_llvm(path: &Path) -> bool { if !path.exists() { return false; } @@ -171,7 +251,7 @@ pub fn is_valid_llvm_14(path: &Path) -> bool { && output.status.success() { let version = String::from_utf8_lossy(&output.stdout); - return version.starts_with("14."); + return is_required_llvm_version(&version); } false @@ -183,13 +263,7 @@ pub fn is_valid_llvm_14(path: &Path) -> bool { /// /// Returns an error if LLVM is not found or version cannot be determined pub fn get_llvm_version(path: &Path) -> Result { - #[cfg(target_os = "windows")] - let llvm_config = path.join("bin").join("llvm-config.exe"); - - #[cfg(not(target_os = "windows"))] - let llvm_config = path.join("bin").join("llvm-config"); - - let output = Command::new(&llvm_config) + let output = Command::new(llvm_config_path(path)) .arg("--version") .output() .map_err(|e| Error::Llvm(format!("Failed to run llvm-config: {e}")))?; @@ -201,11 +275,83 @@ pub fn get_llvm_version(path: &Path) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +/// Get LLVM's configured shared/static library mode. +/// +/// # Errors +/// +/// Returns an error if `llvm-config --shared-mode` fails. +pub fn get_llvm_shared_mode(path: &Path) -> Result { + let output = Command::new(llvm_config_path(path)) + .arg("--shared-mode") + .output() + .map_err(|e| Error::Llvm(format!("Failed to run llvm-config: {e}")))?; + + if !output.status.success() { + return Err(Error::Llvm( + "llvm-config --shared-mode returned non-zero status".into(), + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Get the shared LLVM library names reported by `llvm-config`, if available. +#[must_use] +pub fn get_llvm_shared_libraries(path: &Path) -> Option { + let output = Command::new(llvm_config_path(path)) + .args(["--libnames", "--link-shared", "core"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let libraries = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if libraries.is_empty() { + None + } else { + Some(libraries) + } +} + +/// Get LLVM's library directory as reported by `llvm-config --libdir`. +/// +/// # Errors +/// +/// Returns an error if `llvm-config --libdir` fails. +pub fn get_llvm_libdir(path: &Path) -> Result { + let output = Command::new(llvm_config_path(path)) + .arg("--libdir") + .output() + .map_err(|e| Error::Llvm(format!("Failed to run llvm-config: {e}")))?; + + if !output.status.success() { + return Err(Error::Llvm( + "llvm-config --libdir returned non-zero status".into(), + )); + } + + Ok(PathBuf::from( + String::from_utf8_lossy(&output.stdout).trim(), + )) +} + +fn llvm_config_path(path: &Path) -> PathBuf { + let exe_name = if cfg!(windows) { + "llvm-config.exe" + } else { + "llvm-config" + }; + + path.join("bin").join(exe_name) +} + /// Find a specific LLVM tool by name #[must_use] pub fn find_tool(tool_name: &str) -> Option { let repo_root = get_repo_root_from_manifest(); - let llvm_path = find_llvm_14(repo_root)?; + let llvm_path = find_configured_or_detected_llvm(repo_root)?; let tool_path = if cfg!(windows) { llvm_path.join("bin").join(format!("{tool_name}.exe")) @@ -273,26 +419,28 @@ fn find_cargo_project_root_from(start: &Path) -> Option { first_match } -/// Print a helpful error message when LLVM 14 is not found +/// Print a helpful error message when the required LLVM version is not found pub fn print_llvm_not_found_error() { let cmd = get_pecos_command(); eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("ERROR: LLVM 14 not found!"); + eprintln!("ERROR: LLVM {REQUIRED_VERSION} not found!"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("PECOS requires LLVM version 14 for QIS program execution."); - eprintln!(); - eprintln!("Option 1 - Install LLVM 14 for PECOS (recommended):"); - eprintln!(); - eprintln!(" {cmd} install llvm"); + eprintln!("PECOS requires LLVM version {REQUIRED_VERSION} for QIS program execution."); eprintln!(); + if installer::managed_install_unavailable_reason().is_none() { + eprintln!("Option 1 - Install LLVM {REQUIRED_VERSION} for PECOS (recommended):"); + eprintln!(); + eprintln!(" {cmd} install llvm"); + eprintln!(); + } #[cfg(target_os = "macos")] { - eprintln!("Option 2 - Use system LLVM via Homebrew:"); + eprintln!("Use system LLVM via Homebrew:"); eprintln!(); - eprintln!(" brew install llvm@14"); + eprintln!(" brew install llvm@21"); eprintln!(" {cmd} llvm configure"); eprintln!(); } @@ -301,14 +449,18 @@ pub fn print_llvm_not_found_error() { { eprintln!("Option 2 - Use system LLVM via package manager:"); eprintln!(); - eprintln!(" sudo apt install llvm-14 # Debian/Ubuntu"); + eprintln!(" Install LLVM 21.1 through your distribution packages if available"); eprintln!(" {cmd} llvm configure"); eprintln!(); } #[cfg(target_os = "windows")] { - eprintln!("For Windows, use the PECOS installer (Option 1) above."); + eprintln!("The official Windows LLVM installer is not sufficient for PECOS."); + eprintln!("Use scripts\\ci\\install-llvm-21-windows.ps1 for the conda-forge"); + eprintln!("LLVM 21.1 toolchain, then configure its Library prefix:"); + eprintln!(); + eprintln!(" {cmd} llvm configure %USERPROFILE%\\.pecos\\deps\\llvm-21.1\\Library"); eprintln!(); } @@ -420,4 +572,46 @@ mod tests { let result = find_cargo_project_root_from(&deep); assert_eq!(result.as_deref(), Some(root)); } + + #[test] + fn required_llvm_version_matches_only_21_1_series() { + assert!(is_required_llvm_version("21.1")); + assert!(is_required_llvm_version("21.1.8")); + assert!(is_required_llvm_version("21.1.8git")); + assert!(!is_required_llvm_version("21.0.9")); + assert!(!is_required_llvm_version("21.10.0")); + assert!(!is_required_llvm_version("22.0.0")); + } + + #[test] + fn normalize_path_string_strips_windows_verbatim_drive_prefix() { + assert_eq!( + normalize_path_string(r"\\?\C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library"), + "C:/Users/runneradmin/.pecos/deps/llvm-21.1/Library" + ); + assert_eq!( + normalize_path_string("//?/C:/Users/runneradmin/.pecos/deps/llvm-21.1/Library"), + "C:/Users/runneradmin/.pecos/deps/llvm-21.1/Library" + ); + } + + #[test] + fn normalize_path_string_strips_windows_verbatim_unc_prefix() { + assert_eq!( + normalize_path_string(r"\\?\UNC\server\share\llvm-21.1"), + "//server/share/llvm-21.1" + ); + } + + #[test] + fn normalize_path_string_leaves_non_verbatim_paths_alone() { + assert_eq!( + normalize_path_string("/home/ciaranra/.pecos/deps/llvm-21.1"), + "/home/ciaranra/.pecos/deps/llvm-21.1" + ); + assert_eq!( + normalize_path_string("//server/share/llvm-21.1"), + "//server/share/llvm-21.1" + ); + } } diff --git a/crates/pecos-build/src/llvm/config.rs b/crates/pecos-build/src/llvm/config.rs index 9ebd6a09f..1756b57f5 100644 --- a/crates/pecos-build/src/llvm/config.rs +++ b/crates/pecos-build/src/llvm/config.rs @@ -2,8 +2,8 @@ use crate::errors::{Error, Result}; use crate::llvm::{ - find_cargo_project_root, find_llvm_14, get_pecos_command, get_repo_root_from_manifest, - is_valid_llvm_14, + LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, find_cargo_project_root, find_llvm, get_pecos_command, + get_repo_root_from_manifest, is_valid_llvm, normalize_path_string, path_to_env_string, }; use std::fs; use std::path::{Path, PathBuf}; @@ -15,9 +15,9 @@ pub struct ConfigValidation { pub configured_path: Option, /// Whether the configured path exists pub path_exists: bool, - /// Whether the configured path is valid LLVM 14 - pub path_is_valid_llvm14: bool, - /// Path that `find_llvm_14` would return + /// Whether the configured path is the required LLVM version + pub path_is_valid_llvm: bool, + /// Path that `find_llvm` would return pub detected_path: Option, /// Whether config matches detected LLVM pub config_matches_detected: bool, @@ -27,7 +27,7 @@ impl ConfigValidation { /// Check if the configuration is healthy #[must_use] pub fn is_healthy(&self) -> bool { - self.configured_path.is_some() && self.path_exists && self.path_is_valid_llvm14 + self.configured_path.is_some() && self.path_exists && self.path_is_valid_llvm } /// Print validation warnings if there are issues @@ -44,21 +44,21 @@ impl ConfigValidation { ); eprintln!(); eprintln!("To fix this:"); - eprintln!(" 1. Install LLVM 14 for PECOS (recommended):"); + eprintln!(" 1. Install LLVM {REQUIRED_VERSION} for PECOS (recommended):"); eprintln!(" {cmd} install llvm"); if self.detected_path.is_some() { eprintln!(" 2. Or use the detected system LLVM:"); eprintln!(" {cmd} llvm configure"); } - } else if !self.path_is_valid_llvm14 { + } else if !self.path_is_valid_llvm { eprintln!(); eprintln!( - "Warning: .cargo/config.toml points to {} which is not valid LLVM 14", + "Warning: .cargo/config.toml points to {} which is not valid LLVM {REQUIRED_VERSION}", configured.display() ); eprintln!(); eprintln!("To fix this:"); - eprintln!(" 1. Install LLVM 14 for PECOS (recommended):"); + eprintln!(" 1. Install LLVM {REQUIRED_VERSION} for PECOS (recommended):"); eprintln!(" {cmd} install llvm"); if self.detected_path.is_some() { eprintln!(" 2. Or use the detected system LLVM:"); @@ -83,7 +83,7 @@ impl ConfigValidation { eprintln!("Warning: No LLVM configured in .cargo/config.toml"); eprintln!(); eprintln!("To fix this:"); - eprintln!(" 1. Install LLVM 14 for PECOS (recommended):"); + eprintln!(" 1. Install LLVM {REQUIRED_VERSION} for PECOS (recommended):"); eprintln!(" {cmd} install llvm"); eprintln!(" 2. Or use the detected system LLVM:"); eprintln!(" {cmd} llvm configure"); @@ -94,8 +94,8 @@ impl ConfigValidation { /// Read the configured LLVM path from .cargo/config.toml /// /// Handles both TOML formats: -/// `LLVM_SYS_140_PREFIX = "/path/to/llvm"` -/// `LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true }` +/// `LLVM_SYS_211_PREFIX = "/path/to/llvm"` +/// `LLVM_SYS_211_PREFIX = { value = "/path/to/llvm", force = true }` #[must_use] pub fn read_configured_llvm_path() -> Option { let project_root = find_cargo_project_root()?; @@ -104,18 +104,18 @@ pub fn read_configured_llvm_path() -> Option { let table: toml::Table = content.parse().ok()?; let env = table.get("env")?; - let entry = env.get("LLVM_SYS_140_PREFIX")?; + let entry = env.get(LLVM_SYS_PREFIX_ENV)?; - // Simple string: LLVM_SYS_140_PREFIX = "/path" + // Simple string: LLVM_SYS_211_PREFIX = "/path" if let Some(s) = entry.as_str() { - return Some(PathBuf::from(s)); + return Some(PathBuf::from(normalize_path_string(s))); } - // Inline table: LLVM_SYS_140_PREFIX = { value = "/path", force = true } + // Inline table: LLVM_SYS_211_PREFIX = { value = "/path", force = true } if let Some(t) = entry.as_table() && let Some(v) = t.get("value").and_then(|v| v.as_str()) { - return Some(PathBuf::from(v)); + return Some(PathBuf::from(normalize_path_string(v))); } None @@ -126,10 +126,10 @@ pub fn read_configured_llvm_path() -> Option { pub fn validate_llvm_config() -> ConfigValidation { let configured_path = read_configured_llvm_path(); let repo_root = get_repo_root_from_manifest(); - let detected_path = find_llvm_14(repo_root); + let detected_path = find_llvm(repo_root); - let (path_exists, path_is_valid_llvm14) = if let Some(ref path) = configured_path { - (path.exists(), is_valid_llvm_14(path)) + let (path_exists, path_is_valid_llvm) = if let Some(ref path) = configured_path { + (path.exists(), is_valid_llvm(path)) } else { (false, false) }; @@ -143,7 +143,7 @@ pub fn validate_llvm_config() -> ConfigValidation { ConfigValidation { configured_path, path_exists, - path_is_valid_llvm14, + path_is_valid_llvm, detected_path, config_matches_detected, } @@ -151,18 +151,18 @@ pub fn validate_llvm_config() -> ConfigValidation { /// Automatically configure LLVM for PECOS /// -/// This function determines the best LLVM 14 installation to use and writes +/// This function determines the best LLVM installation to use and writes /// it to `.cargo/config.toml` with `force=true`. /// /// Priority order: -/// 1. `~/.pecos/deps/llvm` (PECOS-managed LLVM, new path) +/// 1. `~/.pecos/deps/llvm-{version}` (PECOS-managed LLVM, new path) /// 2. `~/.pecos/llvm` (legacy path) -/// 3. `LLVM_SYS_140_PREFIX` environment variable -/// 4. System LLVM 14 (Homebrew, system paths, etc.) +/// 3. `LLVM_SYS_211_PREFIX` environment variable +/// 4. System LLVM (Homebrew, system paths, etc.) /// /// # Errors /// -/// Returns an error if no suitable LLVM 14 installation could be found +/// Returns an error if no suitable LLVM installation could be found pub fn auto_configure_llvm(project_root: Option) -> Result { // Priority 1 & 2: Check ~/.pecos/deps/llvm and legacy ~/.pecos/llvm let mut pecos_llvm_paths = Vec::new(); @@ -174,11 +174,15 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { } #[cfg(target_os = "windows")] if let Some(home_dir) = dirs::home_dir() { - pecos_llvm_paths.push(home_dir.join(".pecos").join("LLVM-14")); + pecos_llvm_paths.push( + home_dir + .join(".pecos") + .join(format!("LLVM-{REQUIRED_VERSION}")), + ); } for pecos_llvm in pecos_llvm_paths { - if is_valid_llvm_14(&pecos_llvm) { + if is_valid_llvm(&pecos_llvm) { let project_root = project_root .or_else(get_repo_root_from_manifest) .or_else(find_cargo_project_root) @@ -189,10 +193,10 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { } } - // Priority 2: Check LLVM_SYS_140_PREFIX - if let Ok(sys_prefix) = std::env::var("LLVM_SYS_140_PREFIX") { + // Priority 2: Check LLVM_SYS_211_PREFIX + if let Ok(sys_prefix) = std::env::var(LLVM_SYS_PREFIX_ENV) { let path = PathBuf::from(&sys_prefix); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { let project_root = project_root .or_else(get_repo_root_from_manifest) .or_else(find_cargo_project_root) @@ -203,9 +207,9 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { } } - // Priority 3: Scan system for LLVM 14 + // Priority 3: Scan system for LLVM let repo_root = get_repo_root_from_manifest(); - if let Some(detected_path) = find_llvm_14(repo_root) { + if let Some(detected_path) = find_llvm(repo_root) { let project_root = project_root .or_else(get_repo_root_from_manifest) .or_else(find_cargo_project_root) @@ -215,7 +219,9 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { return Ok(detected_path); } - Err(Error::Llvm("No suitable LLVM 14 installation found".into())) + Err(Error::Llvm(format!( + "No suitable LLVM {REQUIRED_VERSION} installation found" + ))) } /// Write or update `.cargo/config.toml` with LLVM configuration @@ -231,9 +237,9 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { /// cannot be written. pub fn write_cargo_config(project_root: &Path, llvm_path: &Path, force: bool) -> Result<()> { // Forward slashes keep the value backslash-escape-free in TOML. - let llvm_path_str = llvm_path.to_string_lossy().replace('\\', "/"); + let llvm_path_str = path_to_env_string(llvm_path); let mut cfg = crate::cargo_config::CargoConfig::open(project_root)?; - cfg.set_env("LLVM_SYS_140_PREFIX", &llvm_path_str, force)?; + cfg.set_env(LLVM_SYS_PREFIX_ENV, &llvm_path_str, force)?; cfg.save()?; Ok(()) } diff --git a/crates/pecos-build/src/llvm/installer.rs b/crates/pecos-build/src/llvm/installer.rs index ac02ef691..3c4fcf64b 100644 --- a/crates/pecos-build/src/llvm/installer.rs +++ b/crates/pecos-build/src/llvm/installer.rs @@ -1,38 +1,91 @@ -//! LLVM 14.0.6 installation functionality +//! LLVM 21.1 installation functionality #![allow(clippy::case_sensitive_file_extension_comparisons)] use crate::errors::{Error, Result}; +#[cfg(target_os = "linux")] use sha2::{Digest, Sha256}; use std::fs; use std::io; use std::path::{Path, PathBuf}; -/// Known SHA256 checksums for LLVM 14.0.6 downloads -const LLVM_CHECKSUMS: &[(&str, &str)] = &[ - ( - "clang+llvm-14.0.6-x86_64-apple-darwin.tar.xz", - "e6cc6b8279661fd4452c2847cb8e55ce1e54e1faf4ab497b37c85ffdb6685e7c", - ), - ( - "clang+llvm-14.0.6-arm64-apple-darwin22.3.0.tar.xz", - "82f4f7607a16c9aaf7314b945bde6a4639836ec9d2b474ebb3a31dee33e3c15a", - ), - ( - "clang+llvm-14.0.6-x86_64-linux-gnu-rhel-8.4.tar.xz", - "7412026be8bb8f6b4c25ef58c7a1f78ed5ea039d94f0fa633a386de9c60a6942", - ), - ( - "clang+llvm-14.0.6-aarch64-linux-gnu.tar.xz", - "1a81fda984f5e607584916fdf69cf41e5385b219b983544d2c1a14950d5a65cf", - ), - ( - "LLVM-14.0.6-win64.7z", - "611e7a39363a2b63267d012a05f83ea9ce2b432a448890459c9412233327ac11", - ), +const LLVM_RELEASE_VERSION: &str = "21.1.8"; + +/// LLVM release version installed by the managed installer. +#[must_use] +pub const fn release_version() -> &'static str { + LLVM_RELEASE_VERSION +} + +/// Explain why PECOS cannot provide a managed shared LLVM install here. +#[must_use] +pub fn managed_install_unavailable_reason() -> Option<&'static str> { + #[cfg(target_os = "linux")] + { + None + } + + #[cfg(target_os = "macos")] + { + Some( + "PECOS-managed shared LLVM is currently available only on \ + Debian/Ubuntu-compatible Linux. On macOS, install LLVM 21 with \ + Homebrew (`brew install llvm@21`) and run `pecos llvm configure`.", + ) + } + + #[cfg(target_os = "windows")] + { + Some( + "PECOS-managed LLVM is not implemented in the CLI on Windows yet. \ + Use `scripts\\ci\\install-llvm-21-windows.ps1` to install the \ + conda-forge LLVM 21.1 toolchain under `%USERPROFILE%\\.pecos\\deps`, \ + then run `pecos llvm configure \ + %USERPROFILE%\\.pecos\\deps\\llvm-21.1\\Library`, or configure \ + your own full LLVM 21.1 install.", + ) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + Some( + "PECOS-managed shared LLVM is currently available only on \ + Debian/Ubuntu-compatible Linux. Install shared LLVM 21 with your \ + system package manager and run `pecos llvm configure /path/to/llvm`.", + ) + } +} + +#[cfg(target_os = "linux")] +const APT_LLVM_PACKAGES: &[&str] = &[ + "libllvm21", + "llvm-21", + "llvm-21-dev", + "llvm-21-linker-tools", + "clang-21", + "libclang-common-21-dev", + "libclang-cpp21", + "libclang1-21", ]; -/// Install LLVM 14.0.6 to `~/.pecos/deps/llvm/` +#[cfg(target_os = "linux")] +#[derive(Clone, Debug)] +struct AptLlvmSource { + base_url: String, + codename: String, + deb_arch: String, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Debug)] +struct AptPackage { + name: String, + version: String, + filename: String, + sha256: String, +} + +/// Install LLVM 21.1 to `~/.pecos/deps/llvm-21.1/` /// /// # Arguments /// * `force` - Force reinstall even if already present @@ -48,32 +101,59 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { // Check if already installed if llvm_dir.exists() { if is_valid_installation(&llvm_dir) { - if !force { - return Err(Error::Llvm( - "LLVM is already installed. Use --force to reinstall.".into(), - )); + if is_shared_installation(&llvm_dir) { + if !force { + return Err(Error::Llvm( + "LLVM is already installed. Use --force to reinstall.".into(), + )); + } + } else if !force { + let recovery = managed_install_unavailable_reason().map_or_else( + || { + "Run `pecos install llvm --force` to replace it with shared LLVM, \ + or configure your own shared LLVM with `pecos llvm configure /path/to/llvm`." + .to_string() + }, + |reason| { + format!( + "{reason} Remove the static managed install manually if you no longer need it." + ) + }, + ); + return Err(Error::Llvm(format!( + "Existing PECOS-managed LLVM is static. {recovery}" + ))); } } else if !force { + let recovery = managed_install_unavailable_reason().map_or_else( + || "Use --force to reinstall.".to_string(), + |reason| { + format!("{reason} Remove the invalid managed install manually if you no longer need it.") + }, + ); return Err(Error::Llvm(format!( - "Existing LLVM directory is not a valid LLVM 14 installation: {}. \ - Use --force to reinstall.", + "Existing LLVM directory is not a valid LLVM {} installation: {}. {recovery}", + super::REQUIRED_VERSION, llvm_dir.display() ))); } } + if let Some(reason) = managed_install_unavailable_reason() { + return Err(Error::Llvm(reason.into())); + } + // Remove existing if force if force && llvm_dir.exists() { println!("Removing existing LLVM installation..."); fs::remove_dir_all(&llvm_dir)?; } - println!("Installing LLVM 14.0.6..."); - println!("This will download ~400MB and may take 5-10 minutes."); + println!("Installing LLVM {LLVM_RELEASE_VERSION}..."); + println!("PECOS-managed LLVM uses a shared LLVM library when available."); + println!("This downloads a large toolchain and may take several minutes."); println!(); - let (url, archive_name) = get_download_url()?; - // Create parent directory if let Some(parent) = llvm_dir.parent() { fs::create_dir_all(parent)?; @@ -83,13 +163,8 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { let temp_base = llvm_dir.parent().unwrap_or(&llvm_dir).join("tmp"); let temp_dir = temp_base.join("llvm"); fs::create_dir_all(&temp_dir)?; - let archive_path = temp_dir.join(&archive_name); - // Download and verify - download_and_verify_with_retry(&url, &archive_path, &archive_name)?; - - // Extract - extract_llvm(&archive_path, &llvm_dir)?; + install_managed_llvm_payload(&llvm_dir, &temp_dir)?; // Cleanup fs::remove_dir_all(&temp_dir)?; @@ -103,12 +178,22 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { "Installation completed but verification failed".into(), )); } + if !is_shared_installation(&llvm_dir) { + return Err(Error::Llvm( + "Managed LLVM installation is static. PECOS requires managed LLVM to provide \ + shared libLLVM; configure a shared system LLVM with `pecos llvm configure /path/to/llvm`." + .into(), + )); + } verify_llvm_runtime(&llvm_dir)?; println!(); println!("Installation complete!"); - println!("LLVM 14.0.6 installed to: {}", llvm_dir.display()); + println!( + "LLVM {LLVM_RELEASE_VERSION} installed to: {}", + llvm_dir.display() + ); if no_configure { println!(); @@ -136,106 +221,175 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { Ok(llvm_dir) } -fn get_download_url() -> Result<(String, String)> { - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; - - match os { - "macos" => { - if arch == "aarch64" { - Ok(( - "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.6/clang+llvm-14.0.6-arm64-apple-darwin22.3.0.tar.xz".to_string(), - "clang+llvm-14.0.6-arm64-apple-darwin22.3.0.tar.xz".to_string(), - )) - } else { - Ok(( - "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.6/clang+llvm-14.0.6-x86_64-apple-darwin.tar.xz".to_string(), - "clang+llvm-14.0.6-x86_64-apple-darwin.tar.xz".to_string(), - )) - } - } - "linux" => { - if arch == "x86_64" { - Ok(( - "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.6/clang+llvm-14.0.6-x86_64-linux-gnu-rhel-8.4.tar.xz".to_string(), - "clang+llvm-14.0.6-x86_64-linux-gnu-rhel-8.4.tar.xz".to_string(), - )) - } else if arch == "aarch64" { - Ok(( - "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.6/clang+llvm-14.0.6-aarch64-linux-gnu.tar.xz".to_string(), - "clang+llvm-14.0.6-aarch64-linux-gnu.tar.xz".to_string(), - )) - } else { - Err(Error::Llvm(format!("Unsupported Linux architecture: {arch}"))) - } +fn install_managed_llvm_payload(llvm_dir: &Path, temp_dir: &Path) -> Result<()> { + #[cfg(target_os = "linux")] + { + if let Some(source) = detect_apt_llvm_source() { + return install_linux_apt_llvm(llvm_dir, temp_dir, &source); } - "windows" => Ok(( - "https://github.com/PLC-lang/llvm-package-windows/releases/download/v14.0.6/LLVM-14.0.6-win64.7z".to_string(), - "LLVM-14.0.6-win64.7z".to_string(), - )), - _ => Err(Error::Llvm(format!("Unsupported operating system: {os}"))), + + Err(Error::Llvm( + "PECOS-managed shared LLVM on Linux currently requires a Debian/Ubuntu-compatible \ + apt.llvm.org repository. Install shared LLVM 21 with your system package manager and \ + run `pecos llvm configure /path/to/llvm`." + .into(), + )) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (llvm_dir, temp_dir); + Err(Error::Llvm( + managed_install_unavailable_reason() + .unwrap_or("PECOS-managed shared LLVM is not available on this platform.") + .into(), + )) } } -fn download_and_verify_with_retry(url: &str, dest: &PathBuf, archive_name: &str) -> Result<()> { - const MAX_RETRIES: u32 = 5; - const BASE_DELAY_SECS: u64 = 10; - - for attempt in 1..=MAX_RETRIES { - if attempt > 1 { - // Exponential backoff: 10s, 20s, 40s, 80s - let delay_secs = BASE_DELAY_SECS * (1 << (attempt - 2)); - println!(); - println!("Retry attempt {attempt}/{MAX_RETRIES} (waiting {delay_secs}s)..."); - std::thread::sleep(std::time::Duration::from_secs(delay_secs)); - } +#[cfg(target_os = "linux")] +fn detect_apt_llvm_source() -> Option { + let os_release = fs::read_to_string("/etc/os-release").ok()?; + let codename = os_release_value(&os_release, "UBUNTU_CODENAME") + .or_else(|| os_release_value(&os_release, "VERSION_CODENAME"))?; + let deb_arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + _ => return None, + }; - let _ = fs::remove_file(dest); + Some(AptLlvmSource { + base_url: "https://apt.llvm.org".to_string(), + codename, + deb_arch: deb_arch.to_string(), + }) +} - if let Err(e) = download_llvm(url, dest) { - if attempt < MAX_RETRIES { - eprintln!("Download error: {e}"); - continue; - } - return Err(e); +#[cfg(target_os = "linux")] +fn os_release_value(contents: &str, key: &str) -> Option { + contents.lines().find_map(|line| { + let (name, value) = line.split_once('=')?; + if name != key { + return None; } + let value = value.trim().trim_matches('"').to_string(); + (!value.is_empty()).then_some(value) + }) +} + +#[cfg(target_os = "linux")] +fn install_linux_apt_llvm(llvm_dir: &Path, temp_dir: &Path, source: &AptLlvmSource) -> Result<()> { + println!( + "Using apt.llvm.org shared LLVM packages for {} {}.", + source.codename, source.deb_arch + ); + + let packages_url = format!( + "{}/{}/dists/llvm-toolchain-{}-21/main/binary-{}/Packages.gz", + source.base_url, source.codename, source.codename, source.deb_arch + ); + let packages_gz = temp_dir.join("Packages.gz"); + download_file(&packages_url, &packages_gz, "LLVM package index")?; + + let packages = read_apt_packages(&packages_gz)?; + let selected = select_apt_packages(&packages)?; + let deb_dir = temp_dir.join("debs"); + let root_dir = temp_dir.join("root"); + fs::create_dir_all(&deb_dir)?; + fs::create_dir_all(&root_dir)?; + + for package in selected { + let url = format!( + "{}/{}/{}", + source.base_url, source.codename, package.filename + ); + let filename = Path::new(&package.filename).file_name().ok_or_else(|| { + Error::Archive(format!("Invalid package filename: {}", package.filename)) + })?; + let deb_path = deb_dir.join(filename); + println!("Downloading {} {}...", package.name, package.version); + download_file(&url, &deb_path, &package.name)?; + verify_checksum_value(&deb_path, &package.sha256)?; + extract_deb_data(&deb_path, &root_dir)?; + } - // Check for empty downloads (CDN/rate limit issues) - let file_size = fs::metadata(dest).map_or(0, |m| m.len()); - if file_size == 0 { - if attempt < MAX_RETRIES { - eprintln!("Download returned empty file (possible CDN issue)"); - continue; + install_apt_root_as_llvm_prefix(&root_dir, llvm_dir, &source.deb_arch)?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn read_apt_packages(packages_gz: &Path) -> Result> { + let file = fs::File::open(packages_gz)?; + let mut decoder = flate2::read::GzDecoder::new(file); + let mut contents = String::new(); + io::Read::read_to_string(&mut decoder, &mut contents)?; + + let mut packages = Vec::new(); + for stanza in contents.split("\n\n") { + let mut name = None; + let mut version = None; + let mut filename = None; + let mut sha256 = None; + + for line in stanza.lines() { + if let Some(value) = line.strip_prefix("Package: ") { + name = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("Version: ") { + version = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("Filename: ") { + filename = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("SHA256: ") { + sha256 = Some(value.trim().to_string()); } - return Err(Error::Llvm( - "Download returned empty file after all retries".into(), - )); } - match verify_checksum(dest, archive_name) { - Ok(()) => return Ok(()), - Err(e) => { - if attempt < MAX_RETRIES { - eprintln!(); - eprintln!("Checksum verification failed. Retrying..."); - let _ = fs::remove_file(dest); - continue; - } - return Err(e); - } + if let (Some(name), Some(version), Some(filename), Some(sha256)) = + (name, version, filename, sha256) + { + packages.push(AptPackage { + name, + version, + filename, + sha256, + }); } } - Err(Error::Llvm( - "Download and verification failed after all retries".into(), - )) + Ok(packages) } -fn download_llvm(url: &str, dest: &PathBuf) -> Result<()> { - print!("Downloading LLVM... "); +#[cfg(target_os = "linux")] +fn select_apt_packages(packages: &[AptPackage]) -> Result> { + let mut selected = Vec::with_capacity(APT_LLVM_PACKAGES.len()); + for package_name in APT_LLVM_PACKAGES { + let package = packages + .iter() + .rev() + .find(|package| { + package.name == *package_name && package.version.contains(LLVM_RELEASE_VERSION) + }) + .ok_or_else(|| { + Error::Llvm(format!( + "apt.llvm.org package {package_name} {LLVM_RELEASE_VERSION} was not found" + )) + })?; + selected.push(package.clone()); + } + Ok(selected) +} + +#[cfg(target_os = "linux")] +fn download_file(url: &str, dest: &Path, label: &str) -> Result<()> { + print!("Downloading {label}... "); io::Write::flush(&mut io::stdout())?; let response = reqwest::blocking::get(url).map_err(|e| Error::Http(e.to_string()))?; + if !response.status().is_success() { + return Err(Error::Http(format!( + "GET {url} failed: {}", + response.status() + ))); + } let total_size = response.content_length().unwrap_or(0); let mut file = fs::File::create(dest)?; @@ -257,143 +411,261 @@ fn download_llvm(url: &str, dest: &PathBuf) -> Result<()> { #[allow(clippy::cast_precision_loss)] let progress = (downloaded as f64 / total_size as f64) * 100.0; if progress - last_print >= 1.0 { - print!("\rDownloading LLVM... {progress:.0}%"); + print!("\rDownloading {label}... {progress:.0}%"); io::Write::flush(&mut io::stdout())?; last_print = progress; } } } - println!("\rDownloading LLVM... Done ({} MB)", downloaded / 1_000_000); + println!( + "\rDownloading {label}... Done ({} MB)", + downloaded / 1_000_000 + ); Ok(()) } -fn verify_checksum(file_path: &PathBuf, archive_name: &str) -> Result<()> { +#[cfg(target_os = "linux")] +fn verify_checksum_value(file_path: &Path, expected: &str) -> Result<()> { print!("Verifying checksum... "); io::Write::flush(&mut io::stdout())?; + let computed_hash = sha256_file(file_path)?; + if computed_hash == expected { + println!("OK"); + Ok(()) + } else { + println!("FAILED"); + Err(Error::Sha256Mismatch { + expected: expected.to_string(), + actual: computed_hash, + }) + } +} + +#[cfg(target_os = "linux")] +fn sha256_file(file_path: &Path) -> Result { let data = fs::read(file_path)?; let mut hasher = Sha256::new(); Digest::update(&mut hasher, &data); - let computed_hash = hasher.finalize().iter().fold(String::new(), |mut s, b| { + Ok(hasher.finalize().iter().fold(String::new(), |mut s, b| { use std::fmt::Write; write!(s, "{b:02x}").unwrap(); s - }); - - let expected_hash = LLVM_CHECKSUMS - .iter() - .find(|(name, _)| *name == archive_name) - .map(|(_, hash)| *hash); - - match expected_hash { - Some(expected) if !expected.is_empty() => { - if computed_hash == expected { - println!("OK"); - Ok(()) - } else { - println!("FAILED"); - Err(Error::Sha256Mismatch { - expected: expected.to_string(), - actual: computed_hash, - }) - } + })) +} + +#[cfg(target_os = "linux")] +fn extract_deb_data(deb_path: &Path, dest: &Path) -> Result<()> { + let data = fs::read(deb_path)?; + if !data.starts_with(b"!\n") { + return Err(Error::Archive(format!( + "{} is not a Debian ar archive", + deb_path.display() + ))); + } + + let mut offset = 8usize; + while offset + 60 <= data.len() { + let header = &data[offset..offset + 60]; + offset += 60; + + let name = String::from_utf8_lossy(&header[0..16]) + .trim() + .trim_end_matches('/') + .to_string(); + let size_text = String::from_utf8_lossy(&header[48..58]).trim().to_string(); + let size: usize = size_text.parse().map_err(|e| { + Error::Archive(format!( + "Invalid ar member size in {}: {e}", + deb_path.display() + )) + })?; + if offset + size > data.len() { + return Err(Error::Archive(format!( + "Truncated ar member in {}", + deb_path.display() + ))); } - _ => { - println!("Skipped (checksum not available)"); - Ok(()) + + let member = &data[offset..offset + size]; + offset += size + (size % 2); + + match name.as_str() { + "data.tar.zst" => { + let decoder = zstd::stream::read::Decoder::new(member)?; + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest)?; + return Ok(()); + } + "data.tar.xz" => { + let decoder = xz2::read::XzDecoder::new(member); + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest)?; + return Ok(()); + } + "data.tar.gz" => { + let decoder = flate2::read::GzDecoder::new(member); + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest)?; + return Ok(()); + } + _ => {} } } -} -fn extract_llvm(archive: &PathBuf, dest: &PathBuf) -> Result<()> { - print!("Extracting LLVM... "); - io::Write::flush(&mut io::stdout())?; - - let file_name = archive - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| Error::Archive("Could not determine archive name".into()))?; + Err(Error::Archive(format!( + "{} did not contain a supported data.tar member", + deb_path.display() + ))) +} - if file_name.ends_with(".tar.xz") { - extract_tar_xz(archive, dest)?; - } else if file_name.ends_with(".7z") { - extract_7z(archive, dest)?; - } else { +#[cfg(target_os = "linux")] +fn install_apt_root_as_llvm_prefix(root_dir: &Path, llvm_dir: &Path, deb_arch: &str) -> Result<()> { + let apt_prefix = root_dir.join("usr").join("lib").join("llvm-21"); + if !apt_prefix.exists() { return Err(Error::Archive(format!( - "Unsupported archive format: {file_name}" + "apt.llvm.org packages did not provide {}", + apt_prefix.display() ))); } - println!("Done"); + if llvm_dir.exists() { + fs::remove_dir_all(llvm_dir)?; + } + fs::rename(&apt_prefix, llvm_dir)?; + + let gnu_arch = match deb_arch { + "amd64" => "x86_64-linux-gnu", + "arm64" => "aarch64-linux-gnu", + _ => { + return Err(Error::Archive(format!( + "Unsupported Debian architecture: {deb_arch}" + ))); + } + }; + let system_lib_dir = root_dir.join("usr").join("lib").join(gnu_arch); + let llvm_lib_dir = llvm_dir.join("lib"); + fs::create_dir_all(&llvm_lib_dir)?; + + install_apt_headers(root_dir, llvm_dir)?; + copy_shared_libraries(&system_lib_dir, &llvm_lib_dir)?; + fix_local_shared_library_links(&llvm_lib_dir)?; Ok(()) } -fn extract_tar_xz(archive: &PathBuf, dest: &PathBuf) -> Result<()> { - use tar::Archive; - use xz2::read::XzDecoder; +#[cfg(target_os = "linux")] +fn install_apt_headers(root_dir: &Path, llvm_dir: &Path) -> Result<()> { + let include_dir = llvm_dir.join("include"); + fs::create_dir_all(&include_dir)?; + + copy_dir_contents( + &root_dir + .join("usr") + .join("include") + .join("llvm-21") + .join("llvm"), + &include_dir.join("llvm"), + )?; + copy_dir_contents( + &root_dir + .join("usr") + .join("include") + .join("llvm-c-21") + .join("llvm-c"), + &include_dir.join("llvm-c"), + )?; + Ok(()) +} - let file = fs::File::open(archive)?; - let decompressor = XzDecoder::new(file); - let mut tar_archive = Archive::new(decompressor); +#[cfg(target_os = "linux")] +fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> { + if !source.exists() { + return Err(Error::Archive(format!( + "apt.llvm.org packages did not provide {}", + source.display() + ))); + } - let extract_to = dest - .parent() - .ok_or_else(|| Error::Archive("Invalid destination path".into()))?; - tar_archive.unpack(extract_to)?; + if dest.exists() || fs::symlink_metadata(dest).is_ok() { + let metadata = fs::symlink_metadata(dest)?; + if metadata.is_dir() { + fs::remove_dir_all(dest)?; + } else { + fs::remove_file(dest)?; + } + } + fs::create_dir_all(dest)?; - // Find and rename extracted directory - let archive_name = archive.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - let archive_path_buf = PathBuf::from(archive_name); - let base_name = archive_path_buf - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(archive_name); - let extracted_dir = extract_to.join(base_name); + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + let file_type = entry.file_type()?; + if file_type.is_dir() { + copy_dir_contents(&source_path, &dest_path)?; + } else if file_type.is_file() { + fs::copy(&source_path, &dest_path)?; + } else if file_type.is_symlink() { + let target = fs::read_link(&source_path)?; + std::os::unix::fs::symlink(target, dest_path)?; + } + } + Ok(()) +} - if extracted_dir.exists() && !dest.exists() { - fs::rename(&extracted_dir, dest)?; +#[cfg(target_os = "linux")] +fn copy_shared_libraries(source_dir: &Path, dest_dir: &Path) -> Result<()> { + if !source_dir.exists() { + return Ok(()); } + for entry in fs::read_dir(source_dir)? { + let entry = entry?; + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !file_name.starts_with("lib") || !file_name.contains(".so") { + continue; + } + + let dest = dest_dir.join(file_name); + if dest.exists() || fs::symlink_metadata(&dest).is_ok() { + let _ = fs::remove_file(&dest); + } + fs::copy(&path, dest)?; + } Ok(()) } -fn extract_7z(archive: &PathBuf, dest: &PathBuf) -> Result<()> { - use sevenz_rust::{Password, SevenZReader}; +#[cfg(target_os = "linux")] +fn fix_local_shared_library_links(lib_dir: &Path) -> Result<()> { + replace_symlink(lib_dir, "libLLVM-21.so", "libLLVM.so.21.1")?; + replace_symlink(lib_dir, "libLLVM.so", "libLLVM.so.21.1")?; + replace_symlink(lib_dir, "libLLVM.so.1", "libLLVM.so.21.1")?; - let file = fs::File::open(archive)?; - let len = file.metadata()?.len(); - let password = Password::empty(); - let mut reader = - SevenZReader::new(file, len, password).map_err(|e| Error::Archive(e.to_string()))?; + if lib_dir.join("libclang-cpp.so.21.1").exists() { + replace_symlink(lib_dir, "libclang-cpp.so.21", "libclang-cpp.so.21.1")?; + replace_symlink(lib_dir, "libclang-cpp.so", "libclang-cpp.so.21.1")?; + } - // Windows LLVM archives have flat structure (bin/, lib/, etc. at root) - // Extract directly to destination - fs::create_dir_all(dest)?; + Ok(()) +} - reader - .for_each_entries(|entry, reader| { - let entry_name = entry.name(); - - if entry.is_directory() { - let dir_path = dest.join(entry_name); - fs::create_dir_all(&dir_path).ok(); - } else { - let file_path = dest.join(entry_name); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).ok(); - } - let mut output = fs::File::create(&file_path)?; - io::copy(reader, &mut output)?; - } - Ok(true) - }) - .map_err(|e| Error::Archive(e.to_string()))?; +#[cfg(target_os = "linux")] +fn replace_symlink(lib_dir: &Path, link: &str, target: &str) -> Result<()> { + use std::os::unix::fs::symlink; + let link_path = lib_dir.join(link); + if link_path.exists() || fs::symlink_metadata(&link_path).is_ok() { + let _ = fs::remove_file(&link_path); + } + symlink(target, link_path)?; Ok(()) } -/// Remove local LLVM installation (`~/.pecos/llvm/`) +/// Remove local LLVM installation (`~/.pecos/deps/llvm-{version}/`) /// /// # Errors /// @@ -402,7 +674,10 @@ pub fn uninstall_llvm() -> Result<()> { let llvm_dir = crate::home::get_llvm_dir_path()?; if !llvm_dir.exists() { - println!("LLVM is not installed in ~/.pecos/deps/llvm/"); + println!( + "LLVM is not installed in ~/.pecos/deps/llvm-{}/", + super::REQUIRED_VERSION + ); return Ok(()); } @@ -413,7 +688,7 @@ pub fn uninstall_llvm() -> Result<()> { Ok(()) } -/// Validate that a path contains a complete LLVM 14 installation +/// Validate that a path contains a complete LLVM installation #[must_use] pub fn is_valid_installation(path: &Path) -> bool { let exe_ext = if cfg!(windows) { ".exe" } else { "" }; @@ -429,7 +704,14 @@ pub fn is_valid_installation(path: &Path) -> bool { } } - super::get_llvm_version(path).is_ok_and(|version| version.starts_with("14.")) + super::get_llvm_version(path).is_ok_and(|version| super::is_required_llvm_version(&version)) +} + +/// Return whether an LLVM installation reports shared libLLVM support. +#[must_use] +pub fn is_shared_installation(path: &Path) -> bool { + super::get_llvm_shared_mode(path).is_ok_and(|mode| mode.trim().eq_ignore_ascii_case("shared")) + && super::get_llvm_shared_libraries(path).is_some() } fn verify_llvm_runtime(llvm_dir: &Path) -> Result<()> { @@ -449,8 +731,10 @@ fn verify_llvm_runtime(llvm_dir: &Path) -> Result<()> { if output.status.success() { let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if version.starts_with("14.0") { - println!("OK (version {version})"); + if super::is_required_llvm_version(&version) { + let link_mode = + super::get_llvm_shared_mode(llvm_dir).unwrap_or_else(|_| "unknown".into()); + println!("OK (version {version}, link mode {link_mode})"); Ok(()) } else { println!("FAILED"); @@ -496,7 +780,86 @@ fn apply_platform_fixes(llvm_dir: &Path) -> Result<()> { Ok(()) } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +fn apply_platform_fixes(llvm_dir: &Path) -> Result<()> { + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::process::Command; + + print!("Applying Linux platform fixes... "); + io::Write::flush(&mut io::stdout())?; + + let llvm_config = llvm_dir.join("bin").join("llvm-config"); + let llvm_config_real = llvm_dir.join("bin").join("llvm-config.real"); + let gnu_arch = match std::env::consts::ARCH { + "x86_64" => "x86_64-linux-gnu", + "aarch64" => "aarch64-linux-gnu", + _ => { + println!("Skipped (unsupported architecture)"); + return Ok(()); + } + }; + let missing_zstd_static = format!("/usr/lib/{gnu_arch}/libzstd.a"); + let system_zstd_runtime = [ + PathBuf::from(format!("/lib/{gnu_arch}/libzstd.so.1")), + PathBuf::from(format!("/usr/lib/{gnu_arch}/libzstd.so.1")), + ] + .into_iter() + .find(|path| path.exists()); + + let Ok(output) = Command::new(&llvm_config) + .args(["--system-libs", "--link-static"]) + .output() + else { + println!("Skipped (llvm-config unavailable)"); + return Ok(()); + }; + + let system_libs = String::from_utf8_lossy(&output.stdout); + let Some(system_zstd_runtime) = system_zstd_runtime else { + println!("Skipped"); + return Ok(()); + }; + + if !system_libs.contains(&missing_zstd_static) || Path::new(&missing_zstd_static).exists() { + println!("Skipped"); + return Ok(()); + } + + let local_zstd = llvm_dir.join("lib").join("libzstd.so"); + if !local_zstd.exists() { + symlink(&system_zstd_runtime, &local_zstd)?; + } + + if !llvm_config_real.exists() { + fs::rename(&llvm_config, &llvm_config_real)?; + } + + let escaped_zstd_static = missing_zstd_static.replace('/', "\\/"); + let wrapper = r#"#!/usr/bin/env bash +real="$(dirname "$0")/llvm-config.real" +output="$("$real" "$@")" +status=$? +if [ "$status" -ne 0 ]; then + exit "$status" +fi +case " $* " in + *" --system-libs "*) + output="${output//__PECOS_ZSTD_STATIC__/-lzstd}" + ;; +esac +printf '%s\n' "$output" +"# + .replace("__PECOS_ZSTD_STATIC__", &escaped_zstd_static); + fs::write(&llvm_config, wrapper)?; + let mut permissions = fs::metadata(&llvm_config)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&llvm_config, permissions)?; + + println!("OK"); + Ok(()) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] #[allow(clippy::unnecessary_wraps)] fn apply_platform_fixes(_llvm_dir: &Path) -> Result<()> { Ok(()) diff --git a/crates/pecos-build/src/prompt.rs b/crates/pecos-build/src/prompt.rs index ce650a333..a4850e9b9 100644 --- a/crates/pecos-build/src/prompt.rs +++ b/crates/pecos-build/src/prompt.rs @@ -18,7 +18,7 @@ pub enum PromptMode { /// Prompt the user with a yes/no question. /// -/// - `message`: The question to display (e.g. "Install LLVM 14 to ~/.pecos/deps/llvm/ (~400MB)?") +/// - `message`: The question to display (e.g. "Install LLVM 21.1 to ~/.pecos/deps/llvm-21.1/?") /// - `default_yes`: Whether the default answer is yes (`[Y/n]`) or no (`[y/N]`) /// - `mode`: How to resolve the prompt /// diff --git a/crates/pecos-cli/src/cli.rs b/crates/pecos-cli/src/cli.rs index 1dab58b1e..d0f2ea8de 100644 --- a/crates/pecos-cli/src/cli.rs +++ b/crates/pecos-cli/src/cli.rs @@ -188,12 +188,12 @@ pub enum SeleneCommands { #[derive(Subcommand, Clone)] pub enum LlvmCommands { - /// Check if LLVM 14 is available + /// Check if LLVM 21.1 is available Check { #[arg(short, long)] quiet: bool, }, - /// Ensure LLVM 14 is installed and runtime-valid + /// Ensure LLVM 21.1 is installed and runtime-valid Ensure { /// Require the PECOS-managed installation under ~/.pecos/deps #[arg(long)] @@ -203,8 +203,11 @@ pub enum LlvmCommands { #[arg(long)] no_configure: bool, }, - /// Configure .cargo/config.toml with LLVM path - Configure, + /// Configure .cargo/config.toml with detected LLVM or an explicit LLVM path + Configure { + /// LLVM installation prefix to configure, e.g. /usr/lib/llvm-21 + path: Option, + }, /// Find LLVM installation path Find { #[arg(long)] diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index 4ad0fc4a0..ef71100d1 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -25,12 +25,17 @@ use std::collections::BTreeMap; use std::fmt::Write; +use std::fs; use std::fs::OpenOptions; use std::io::Write as IoWrite; -use std::path::Path; +use std::path::{Path, PathBuf}; + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] +use std::ffi::OsString; use pecos_build::Result; use pecos_build::errors::Error; +use pecos_build::llvm::LLVM_SYS_PREFIX_ENV; /// Collect the build environment for the current platform. /// @@ -41,10 +46,10 @@ 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(); + if let Some(llvm_path) = pecos_build::llvm::find_configured_or_detected_llvm(None) { + let llvm_str = pecos_build::llvm::path_to_env_string(&llvm_path); env.insert("PECOS_LLVM".into(), llvm_str.clone()); - env.insert("LLVM_SYS_140_PREFIX".into(), llvm_str); + env.insert(LLVM_SYS_PREFIX_ENV.into(), llvm_str); // Add LLVM bin to PATH let bin_path = llvm_path.join("bin"); @@ -56,6 +61,21 @@ pub fn collect_env() -> BTreeMap { env.insert("PATH".into(), path.to_string_lossy().into_owned()); } } + + if let Ok(libdir) = pecos_build::llvm::get_llvm_libdir(&llvm_path) { + if pecos_build::llvm::get_llvm_shared_mode(&llvm_path) + .is_ok_and(|mode| mode.trim().eq_ignore_ascii_case("shared")) + { + add_llvm_runtime_library_path(&mut env, &libdir); + } + + if let Some(libclang_dir) = find_libclang_dir(&llvm_path, &libdir) { + env.insert( + "LIBCLANG_PATH".into(), + pecos_build::llvm::path_to_env_string(&libclang_dir), + ); + } + } } // macOS-specific @@ -122,6 +142,76 @@ pub fn collect_env() -> BTreeMap { env } +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn add_llvm_runtime_library_path(env: &mut BTreeMap, libdir: &Path) { + prepend_path_env(env, "LD_LIBRARY_PATH", libdir); +} + +#[cfg(target_os = "macos")] +fn add_llvm_runtime_library_path(env: &mut BTreeMap, libdir: &Path) { + prepend_path_env(env, "DYLD_LIBRARY_PATH", libdir); +} + +#[cfg(target_os = "windows")] +fn add_llvm_runtime_library_path(_env: &mut BTreeMap, _libdir: &Path) { + // Windows LLVM DLLs are expected in bin/, which is already prepended to PATH. +} + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] +fn prepend_path_env(env: &mut BTreeMap, key: &str, first: &Path) { + let current = env + .get(key) + .map(OsString::from) + .or_else(|| std::env::var_os(key)); + let mut entries = vec![first.to_path_buf()]; + if let Some(current) = current { + entries.extend(std::env::split_paths(¤t)); + } + + if let Ok(joined) = std::env::join_paths(entries) { + env.insert(key.to_string(), joined.to_string_lossy().into_owned()); + } +} + +fn find_libclang_dir(llvm_path: &Path, libdir: &Path) -> Option { + let mut candidates = vec![libdir.to_path_buf()]; + if cfg!(windows) { + candidates.insert(0, llvm_path.join("bin")); + } + + candidates + .into_iter() + .find(|candidate| contains_libclang(candidate)) +} + +fn contains_libclang(dir: &Path) -> bool { + let Ok(entries) = fs::read_dir(dir) else { + return false; + }; + + entries + .filter_map(std::result::Result::ok) + .any(|entry| entry.file_name().to_str().is_some_and(is_libclang_filename)) +} + +fn is_libclang_filename(name: &str) -> bool { + if cfg!(windows) { + return name.eq_ignore_ascii_case("libclang.dll"); + } + + if cfg!(target_os = "macos") { + return name == "libclang.dylib" + || (name.starts_with("libclang.") + && Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dylib"))); + } + + name == "libclang.so" + || name.starts_with("libclang.so.") + || name.starts_with("libclang-") && name.contains(".so") +} + /// Print environment in shell-eval format: `export KEY="VALUE"` pub fn print_shell(env: &BTreeMap) { for (key, value) in env { @@ -191,8 +281,12 @@ fn write_github_actions_files( .append(true) .create(true) .open(github_path)?; - if let Some(llvm_path) = env.get("LLVM_SYS_140_PREFIX") { - writeln!(path_file, "{}", Path::new(llvm_path).join("bin").display())?; + if let Some(llvm_path) = env.get(LLVM_SYS_PREFIX_ENV) { + writeln!( + path_file, + "{}", + pecos_build::llvm::path_to_env_string(&Path::new(llvm_path).join("bin")) + )?; } Ok(()) @@ -226,15 +320,15 @@ mod tests { let env_path = std::env::temp_dir().join(format!("pecos-gh-env-{unique}")); let path_path = std::env::temp_dir().join(format!("pecos-gh-path-{unique}")); - let llvm_prefix = Path::new("/opt/pecos/llvm-14"); + let llvm_prefix = Path::new("/opt/pecos/llvm-21.1"); let llvm_prefix_str = llvm_prefix.display().to_string(); - let llvm_bin_str = llvm_prefix.join("bin").display().to_string(); + let llvm_bin_str = pecos_build::llvm::path_to_env_string(&llvm_prefix.join("bin")); let mut env = BTreeMap::new(); - env.insert("LLVM_SYS_140_PREFIX".to_string(), llvm_prefix_str.clone()); + env.insert(LLVM_SYS_PREFIX_ENV.to_string(), llvm_prefix_str.clone()); env.insert( "PATH".to_string(), - "/opt/pecos/llvm-14/bin:/usr/bin".to_string(), + "/opt/pecos/llvm-21.1/bin:/usr/bin".to_string(), ); env.insert("PECOS_LLVM".to_string(), llvm_prefix_str.clone()); @@ -243,7 +337,7 @@ mod tests { let env_file = std::fs::read_to_string(&env_path).unwrap(); let path_file = std::fs::read_to_string(&path_path).unwrap(); - assert!(env_file.contains(&format!("LLVM_SYS_140_PREFIX={llvm_prefix_str}"))); + assert!(env_file.contains(&format!("{LLVM_SYS_PREFIX_ENV}={llvm_prefix_str}"))); assert!(env_file.contains(&format!("PECOS_LLVM={llvm_prefix_str}"))); assert!(!env_file.contains("PATH=")); assert_eq!(path_file.trim(), llvm_bin_str); diff --git a/crates/pecos-cli/src/cli/info.rs b/crates/pecos-cli/src/cli/info.rs index 182cb1a39..7ae10d8e1 100644 --- a/crates/pecos-cli/src/cli/info.rs +++ b/crates/pecos-cli/src/cli/info.rs @@ -3,7 +3,9 @@ use pecos_build::cuda::{find_cuda, get_cuda_version}; use pecos_build::cuquantum::{find_cuquantum, get_cuquantum_version}; use pecos_build::home::{get_cache_dir, get_deps_dir, get_llvm_dir, get_pecos_home}; -use pecos_build::llvm::{find_llvm_14, get_llvm_version, get_repo_root_from_manifest}; +use pecos_build::llvm::{ + LLVM_SYS_PREFIX_ENV, find_llvm, get_llvm_version, get_repo_root_from_manifest, +}; use std::process::Command; /// Run the info command @@ -99,8 +101,8 @@ pub fn run() { println!(" PECOS_CACHE_DIR = {val}"); has_overrides = true; } - if let Ok(val) = std::env::var("LLVM_SYS_140_PREFIX") { - println!(" LLVM_SYS_140_PREFIX = {val}"); + if let Ok(val) = std::env::var(LLVM_SYS_PREFIX_ENV) { + println!(" {LLVM_SYS_PREFIX_ENV} = {val}"); has_overrides = true; } @@ -115,11 +117,11 @@ fn print_toolchain_status() { // LLVM let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_llvm(repo_root) { let version = get_llvm_version(&llvm_path).unwrap_or_else(|_| "unknown".to_string()); - println!(" LLVM 14: {} ({})", version, llvm_path.display()); + println!(" LLVM 21.1: {} ({})", version, llvm_path.display()); } else { - println!(" LLVM 14: not found"); + println!(" LLVM 21.1: not found"); } // CUDA diff --git a/crates/pecos-cli/src/cli/install_cmd.rs b/crates/pecos-cli/src/cli/install_cmd.rs index 6c988b03f..b3119ae44 100644 --- a/crates/pecos-cli/src/cli/install_cmd.rs +++ b/crates/pecos-cli/src/cli/install_cmd.rs @@ -8,7 +8,13 @@ use pecos_build::prompt::{PromptMode, confirm}; const KNOWN_TARGETS: &[&str] = &["cuda", "llvm", "cuquantum", "cmake"]; /// Run the install command -pub fn run(targets: &[String], force: bool, all: bool, no_configure: bool) -> Result<()> { +pub fn run( + targets: &[String], + force: bool, + all: bool, + no_configure: bool, + yes: bool, +) -> Result<()> { let targets: Vec<&str> = if all { KNOWN_TARGETS.to_vec() } else { @@ -58,10 +64,14 @@ pub fn run(targets: &[String], force: bool, all: bool, no_configure: bool) -> Re if confirm( " Install a PECOS-managed copy to ~/.pecos/deps/ instead?", false, - PromptMode::Interactive, + if yes { + PromptMode::AcceptAll + } else { + PromptMode::Interactive + }, ) { println!(); - install_target(target, true, no_configure)?; + install_target(target, true, no_configure, yes)?; } } if *target == "llvm" { @@ -70,7 +80,7 @@ pub fn run(targets: &[String], force: bool, all: bool, no_configure: bool) -> Re } else { println!("[{}/{}] Installing {target}...", i + 1, total); println!(); - install_target(target, force, no_configure)?; + install_target(target, force, no_configure, yes)?; } println!(); } @@ -83,7 +93,7 @@ pub fn run(targets: &[String], force: bool, all: bool, no_configure: bool) -> Re fn find_existing(target: &str) -> Option { match target { "cuda" => pecos_build::cuda::find_cuda(), - "llvm" => pecos_build::llvm::find_llvm_14(None), + "llvm" => pecos_build::llvm::find_llvm(None), "cuquantum" => pecos_build::cuquantum::find_cuquantum(), "cmake" => pecos_build::cmake::find_cmake(), _ => None, @@ -91,12 +101,13 @@ fn find_existing(target: &str) -> Option { } /// Install a single target -fn install_target(target: &str, force: bool, no_configure: bool) -> Result<()> { +fn install_target(target: &str, force: bool, no_configure: bool, yes: bool) -> Result<()> { match target { "cuda" => { pecos_build::cuda::installer::install_cuda(force)?; } "llvm" => { + confirm_managed_llvm_install(yes)?; pecos_build::llvm::installer::install_llvm(force, no_configure)?; } "cuquantum" => { @@ -110,6 +121,44 @@ fn install_target(target: &str, force: bool, no_configure: bool) -> Result<()> { Ok(()) } +fn confirm_managed_llvm_install(yes: bool) -> Result<()> { + let version = pecos_build::llvm::installer::release_version(); + let install_dir = + pecos_build::home::get_versioned_dep_path("llvm", pecos_build::home::LLVM_VERSION)?; + + if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() { + return Err(Error::Config(reason.into())); + } + + println!("PECOS-managed LLVM is the recommended development setup."); + println!( + "This will install LLVM {version} to {}.", + install_dir.display() + ); + println!("Expect a large download and several GB of extracted files."); + println!("The managed install is shared-first; static LLVM is not accepted for"); + println!("the full workspace HUGR test lane because LLVM 21.1 static links can"); + println!("use substantial memory."); + println!(); + println!("To use your own LLVM instead, run:"); + println!(" pecos llvm configure /path/to/llvm"); + println!(); + + let mode = if yes { + PromptMode::AcceptAll + } else { + PromptMode::Interactive + }; + + if confirm("Continue with PECOS-managed LLVM install?", true, mode) { + Ok(()) + } else { + Err(Error::Config( + "LLVM installation cancelled. Configure an existing LLVM with `pecos llvm configure /path/to/llvm`.".into(), + )) + } +} + /// Ensure LLVM is configured in .cargo/config.toml when already installed. /// Auto-configures if not healthy, unless --no-configure was passed. fn ensure_llvm_configured(no_configure: bool) { diff --git a/crates/pecos-cli/src/cli/list.rs b/crates/pecos-cli/src/cli/list.rs index 3290620b7..8b94108a1 100644 --- a/crates/pecos-cli/src/cli/list.rs +++ b/crates/pecos-cli/src/cli/list.rs @@ -4,7 +4,7 @@ use pecos_build::cuda::find_cuda; use pecos_build::cuquantum::find_cuquantum; use pecos_build::deps::list_dependencies; use pecos_build::home::{get_cache_dir, get_deps_dir}; -use pecos_build::llvm::{find_llvm_14, get_llvm_version, get_repo_root_from_manifest}; +use pecos_build::llvm::{find_llvm, get_llvm_version, get_repo_root_from_manifest}; use std::fs; use std::path::Path; @@ -16,13 +16,15 @@ pub fn run(verbose: bool) { // LLVM status let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_llvm(repo_root) { let version = get_llvm_version(&llvm_path) .map(|v| format!(" ({v})")) .unwrap_or_default(); - println!("LLVM 14: {}{version}", llvm_path.display()); + println!("LLVM 21.1: {}{version}", llvm_path.display()); + } else if pecos_build::llvm::installer::managed_install_unavailable_reason().is_some() { + println!("LLVM 21.1: not found (configure shared LLVM 21 manually)"); } else { - println!("LLVM 14: not found (install with: pecos install llvm)"); + println!("LLVM 21.1: not found (install with: pecos install llvm)"); } // CUDA status diff --git a/crates/pecos-cli/src/cli/llvm_cmd.rs b/crates/pecos-cli/src/cli/llvm_cmd.rs index c415a5c3a..b46bf4b49 100644 --- a/crates/pecos-cli/src/cli/llvm_cmd.rs +++ b/crates/pecos-cli/src/cli/llvm_cmd.rs @@ -2,10 +2,14 @@ use super::LlvmCommands; use pecos_build::Result; -use pecos_build::llvm::config::{auto_configure_llvm, validate_llvm_config}; +use pecos_build::llvm::config::{auto_configure_llvm, validate_llvm_config, write_cargo_config}; use pecos_build::llvm::{ - find_llvm_14, find_tool, get_llvm_version, get_pecos_command, get_repo_root_from_manifest, + LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, find_cargo_project_root, + find_configured_or_detected_llvm, find_llvm, find_tool, get_llvm_shared_libraries, + get_llvm_shared_mode, get_llvm_version, get_pecos_command, get_repo_root_from_manifest, + is_valid_llvm, }; +use std::path::Path; /// Run an LLVM subcommand pub fn run(command: LlvmCommands) -> Result<()> { @@ -18,7 +22,7 @@ pub fn run(command: LlvmCommands) -> Result<()> { managed, no_configure, } => run_ensure(managed, no_configure), - LlvmCommands::Configure => run_configure(), + LlvmCommands::Configure { path } => run_configure(path), LlvmCommands::Find { export } => { run_find(export); Ok(()) @@ -34,12 +38,13 @@ pub fn run(command: LlvmCommands) -> Result<()> { fn run_check(quiet: bool) { let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_configured_or_detected_llvm(repo_root) { if !quiet { - println!("LLVM 14 found at: {}", llvm_path.display()); + println!("LLVM 21.1 found at: {}", llvm_path.display()); if let Ok(version) = get_llvm_version(&llvm_path) { println!("Version: {version}"); } + print_link_info(&llvm_path); // Validate configuration let validation = validate_llvm_config(); @@ -53,9 +58,17 @@ fn run_check(quiet: bool) { } else { if !quiet { let cmd = get_pecos_command(); - eprintln!("LLVM 14 not found"); + eprintln!("LLVM 21.1 not found"); eprintln!(); - eprintln!("Install with: `{cmd} install llvm`"); + if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() + { + eprintln!("{reason}"); + eprintln!(); + eprintln!("After installing LLVM 21, configure it with:"); + eprintln!(" {cmd} llvm configure /path/to/llvm"); + } else { + eprintln!("Install with: `{cmd} install llvm`"); + } } std::process::exit(1); } @@ -64,7 +77,7 @@ fn run_check(quiet: bool) { fn run_ensure(managed: bool, no_configure: bool) -> Result<()> { let llvm_path = if managed { ensure_managed_llvm(no_configure)? - } else if let Some(path) = find_llvm_14(get_repo_root_from_manifest()) { + } else if let Some(path) = find_llvm(get_repo_root_from_manifest()) { if !no_configure { auto_configure_llvm(None)?; } @@ -99,8 +112,33 @@ fn ensure_managed_llvm(no_configure: bool) -> Result { Ok(llvm_path) } -fn run_configure() -> Result<()> { - let llvm_path = auto_configure_llvm(None)?; +fn run_configure(path: Option) -> Result<()> { + let llvm_path = if let Some(path) = path { + let input_path = std::path::PathBuf::from(&path); + let llvm_path = input_path.canonicalize().map_err(|e| { + pecos_build::errors::Error::Llvm(format!( + "Could not resolve LLVM path {}: {e}", + input_path.display() + )) + })?; + if !is_valid_llvm(&llvm_path) { + return Err(pecos_build::errors::Error::Llvm(format!( + "{} is not a valid LLVM {REQUIRED_VERSION} installation", + llvm_path.display() + ))); + } + + let project_root = get_repo_root_from_manifest() + .or_else(find_cargo_project_root) + .ok_or_else(|| { + pecos_build::errors::Error::Config("Could not find Cargo project root".into()) + })?; + write_cargo_config(&project_root, &llvm_path, true)?; + llvm_path + } else { + auto_configure_llvm(None)? + }; + println!("Configured LLVM path: {}", llvm_path.display()); println!("Updated .cargo/config.toml"); Ok(()) @@ -108,27 +146,27 @@ fn run_configure() -> Result<()> { fn run_find(export: bool) { let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_configured_or_detected_llvm(repo_root) { if export { - println!("export LLVM_SYS_140_PREFIX=\"{}\"", llvm_path.display()); + println!("export {LLVM_SYS_PREFIX_ENV}=\"{}\"", llvm_path.display()); } else { println!("{}", llvm_path.display()); } } else { - eprintln!("LLVM 14 not found"); + eprintln!("LLVM 21.1 not found"); std::process::exit(1); } } fn run_version() -> Result<()> { let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_configured_or_detected_llvm(repo_root) { let version = get_llvm_version(&llvm_path)?; println!("LLVM version: {version}"); println!("Location: {}", llvm_path.display()); Ok(()) } else { - eprintln!("LLVM 14 not found"); + eprintln!("LLVM 21.1 not found"); std::process::exit(1); } } @@ -138,9 +176,9 @@ fn run_validate(path: Option) -> Result<()> { std::path::PathBuf::from(p) } else { let repo_root = get_repo_root_from_manifest(); - find_llvm_14(repo_root).ok_or_else(|| { + find_configured_or_detected_llvm(repo_root).ok_or_else(|| { pecos_build::errors::Error::Llvm( - "LLVM 14 not found. Specify a path or install first.".into(), + "LLVM 21.1 not found. Specify a path or install first.".into(), ) })? }; @@ -172,10 +210,10 @@ fn run_validate(path: Option) -> Result<()> { // Check version println!(); if let Ok(version) = get_llvm_version(&llvm_path) { - if version.starts_with("14.") { + if pecos_build::llvm::is_required_llvm_version(&version) { println!("Version: {version} [OK]"); } else { - println!("Version: {version} [WARNING: expected 14.x]"); + println!("Version: {version} [WARNING: expected {REQUIRED_VERSION}]"); all_present = false; } } else { @@ -183,6 +221,9 @@ fn run_validate(path: Option) -> Result<()> { all_present = false; } + print_link_info(&llvm_path); + println!(); + println!(); if all_present { println!("Validation: PASSED"); @@ -202,3 +243,15 @@ fn run_tool(name: &str) { std::process::exit(1); } } + +fn print_link_info(llvm_path: &Path) { + if let Ok(mode) = get_llvm_shared_mode(llvm_path) { + println!("Link mode: {mode}"); + } + + if let Some(libraries) = get_llvm_shared_libraries(llvm_path) { + println!("Shared library: {libraries}"); + } else { + println!("Shared library: unavailable"); + } +} diff --git a/crates/pecos-cli/src/cli/migrate_cmd.rs b/crates/pecos-cli/src/cli/migrate_cmd.rs index b9c86561e..197537871 100644 --- a/crates/pecos-cli/src/cli/migrate_cmd.rs +++ b/crates/pecos-cli/src/cli/migrate_cmd.rs @@ -4,7 +4,8 @@ //! into `~/.pecos/deps/` to match the new directory layout. use pecos_build::Result; -use pecos_build::home::{find_legacy_deps, migrate_legacy_dep}; +use pecos_build::home::{find_legacy_dep_status, migrate_legacy_dep}; +use pecos_build::prompt::{PromptMode, confirm}; use std::path::PathBuf; fn find_project_root() -> Result { @@ -20,16 +21,58 @@ fn find_project_root() -> Result { } /// Run the migrate command. -pub fn run() -> Result<()> { - let legacy = find_legacy_deps()?; +pub fn run(mode: PromptMode) -> Result<()> { + let legacy = find_legacy_dep_status()?; - if legacy.is_empty() { - println!("Nothing to migrate. All dependencies are already under ~/.pecos/deps/."); + if !legacy.incompatible.is_empty() { + println!("Found legacy dependencies that cannot be migrated safely:"); + for dep in &legacy.incompatible { + println!(" {} at {}", dep.name, dep.old.display()); + println!(" {}", dep.reason); + if dep.name == "LLVM" { + println!(" This path will not be moved into ~/.pecos/deps/llvm-21.1/."); + println!(" Remove it before installing/configuring LLVM 21.1."); + println!(" Then install LLVM 21.1 with `pecos install llvm`, or configure"); + println!( + " an existing LLVM 21.1 install with `pecos llvm configure /path/to/llvm`." + ); + } + } + println!(); + + for dep in &legacy.incompatible { + if dep.name != "LLVM" { + continue; + } + if confirm( + &format!("Remove incompatible legacy LLVM at {}?", dep.old.display()), + true, + mode, + ) { + print!(" Removing old LLVM..."); + pecos_build::home::remove_incompatible_legacy_dep(dep)?; + println!(" done"); + } else { + println!( + " Keeping old LLVM at {}. It will not be used as LLVM 21.1.", + dep.old.display() + ); + } + } + println!(); + } + + if legacy.migratable.is_empty() { + if legacy.incompatible.is_empty() { + println!("Nothing to migrate. All dependencies are already under ~/.pecos/deps/."); + } else { + println!("No compatible legacy dependencies can be migrated automatically."); + } return Ok(()); } println!("Migrating legacy dependencies to ~/.pecos/deps/:"); - for dep in &legacy { + for dep in &legacy.migratable { print!( " {} : {} -> {}", dep.name, @@ -42,7 +85,7 @@ pub fn run() -> Result<()> { // Update .cargo/config.toml to point to the new paths let llvm_dir = pecos_build::home::get_llvm_dir_path()?; - if llvm_dir.exists() + if pecos_build::llvm::is_valid_llvm(&llvm_dir) && let Ok(project_root) = find_project_root() && pecos_build::llvm::config::write_cargo_config(&project_root, &llvm_dir, true).is_ok() { diff --git a/crates/pecos-cli/src/cli/python_cmd.rs b/crates/pecos-cli/src/cli/python_cmd.rs index 022f70dbd..84c6468a4 100644 --- a/crates/pecos-cli/src/cli/python_cmd.rs +++ b/crates/pecos-cli/src/cli/python_cmd.rs @@ -23,11 +23,16 @@ pub fn run(command: &super::PythonCommands) -> Result<()> { /// Decide whether to install CUDA Python packages for this build. /// -/// Resolution order: -/// - `--cuda` -> always on (caller knows what they want) -/// - `--no-cuda` -> always off (caller opts out) -/// - neither -> auto-detect: include CUDA Python packages when both the -/// toolkit and an NVIDIA GPU are present, otherwise skip +/// Decide whether `pecos python build` builds the CUDA (Rust) backend crate +/// (`pecos-rslib-cuda`). +/// +/// - `--cuda` -> build it. The caller opted in and is responsible for CUDA +/// setup first (e.g. `just build-cuda` runs `setup-quiet`, which installs the +/// cuQuantum SDK that crate's build needs). +/// - `--no-cuda` -> skip it. +/// - neither -> do NOT build it. The auto-detect path (e.g. `just build-lite`) +/// does no CUDA setup, so building the backend here could fail or be slow. When a +/// toolkit + GPU are present, just print a notice on how to enable CUDA. fn resolve_cuda_choice(cuda: bool, no_cuda: bool) -> bool { if cuda { return true; @@ -35,14 +40,16 @@ fn resolve_cuda_choice(cuda: bool, no_cuda: bool) -> bool { if no_cuda { return false; } - let detected = super::cuda_cmd::should_install_cuda_python(); - if detected { + if super::cuda_cmd::should_install_cuda_python() { println!( - "CUDA toolkit + NVIDIA GPU detected -- including CUDA Python packages \ - (cupy, cuquantum, pytket-cutensornet). Pass --no-cuda to skip." + "CUDA toolkit + NVIDIA GPU detected, but `pecos python build` only builds the \ + CUDA (Rust) backend when you pass --cuda. To enable CUDA: run \ + `pecos python build --cuda` (or `just build-cuda`) for the `pecos-rslib-cuda` \ + backend, and `uv sync --group cuda12|cuda13` (or `pecos cuda setup-python`) for \ + the CUDA Python packages." ); } - detected + false } /// Get the repository root @@ -168,23 +175,21 @@ fn run_build(profile: &str, rustflags: Option<&str>, cuda: bool) -> Result<()> { } // Build all rslib crates via maturin (incremental — cargo inside maturin - // handles change detection, skips recompilation when nothing changed) - let crates = ["pecos-rslib", "pecos-rslib-llvm"]; + // handles change detection, skips recompilation when nothing changed). + // The CUDA (Rust) backend is its own crate, built only on an explicit --cuda + // (`cuda` is true only then -- see resolve_cuda_choice); the auto-detect path + // does no CUDA setup, so it must not pull in pecos-rslib-cuda. + let mut crates = vec!["pecos-rslib", "pecos-rslib-llvm"]; + if cuda { + crates.push("pecos-rslib-cuda"); + } for crate_name in crates { let crate_dir = repo_root.join(format!("python/{crate_name}")); if !crate_dir.exists() { continue; } - println!( - "Building {crate_name} ({}{})...", - profile, - if cuda && crate_name == "pecos-rslib" { - " +cuda" - } else { - "" - } - ); + println!("Building {crate_name} ({profile})..."); remove_stale_extension_artifacts(&repo_root, profile, crate_name)?; @@ -258,11 +263,14 @@ fn run_build(profile: &str, rustflags: Option<&str>, cuda: bool) -> Result<()> { let mut pip_cmd = Command::new("uv"); pip_cmd.args(["pip", "install", "--no-deps", "-e"]); - if cuda { - pip_cmd.arg("./python/quantum-pecos[all,cuda]"); - } else { - pip_cmd.arg("./python/quantum-pecos[all]"); - } + // `--no-deps` (above) means this editable install pulls no dependencies, so + // naming a CUDA extra here would be inert: the CUDA Python stack + // (cupy/cuquantum/pytket-cutensornet) is installed separately via + // `uv sync --group cuda12|cuda13` (`just build`'s sync-deps, `pecos setup`, or + // `pecos cuda setup-python`), not by this command. Request the dependency-free + // `[all]` extra, which exists regardless of CUDA toolkit major and avoids an + // unknown-extra warning. + pip_cmd.arg("./python/quantum-pecos[all]"); pip_cmd.current_dir(&repo_root); pip_cmd.env_remove("CONDA_PREFIX"); diff --git a/crates/pecos-cli/src/cli/rust_cmd.rs b/crates/pecos-cli/src/cli/rust_cmd.rs index 8ddda43e0..4aed6dcf0 100644 --- a/crates/pecos-cli/src/cli/rust_cmd.rs +++ b/crates/pecos-cli/src/cli/rust_cmd.rs @@ -3,6 +3,7 @@ use pecos_build::Result; use pecos_build::errors::Error; use serde_json::Value; +use std::path::PathBuf; use std::process::Command; /// FFI crates that need a non-Rust toolchain or external SDK to check / @@ -63,6 +64,72 @@ enum GpuProbeResult { ProbeFailed(String), } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LlvmLinkMode { + Shared, + Static, + Unknown, +} + +impl LlvmLinkMode { + fn from_llvm_config(output: &str) -> Self { + match output.trim().to_ascii_lowercase().as_str() { + "shared" => Self::Shared, + "static" => Self::Static, + _ => Self::Unknown, + } + } +} + +fn detect_cargo_llvm_link_mode() -> Option<(PathBuf, LlvmLinkMode)> { + let llvm_path = pecos_build::llvm::find_configured_or_detected_llvm(None)?; + let link_mode = pecos_build::llvm::get_llvm_shared_mode(&llvm_path) + .map_or(LlvmLinkMode::Unknown, |mode| { + LlvmLinkMode::from_llvm_config(&mode) + }); + Some((llvm_path, link_mode)) +} + +fn reject_static_llvm_workspace_test() -> Result<()> { + let Some((llvm_path, link_mode)) = detect_cargo_llvm_link_mode() else { + return Ok(()); + }; + if matches!(link_mode, LlvmLinkMode::Shared) { + return Ok(()); + } + + if cfg!(target_os = "windows") && matches!(link_mode, LlvmLinkMode::Static) { + println!( + "Windows MSVC uses static LLVM libraries because llvm-sys does not support dynamic LLVM linking on this target." + ); + return Ok(()); + } + + let mode = if matches!(link_mode, LlvmLinkMode::Static) { + "static" + } else { + "unknown" + }; + + let setup_hint = pecos_build::llvm::installer::managed_install_unavailable_reason().map_or( + "Install/configure shared LLVM 21.1 instead, for example `pecos install llvm --force` \ + or `pecos llvm configure /path/to/llvm`." + .to_string(), + |reason| { + format!( + "{reason} `pecos rust test` requires shared LLVM; use targeted Cargo tests if you must build against static LLVM." + ) + }, + ); + + Err(Error::Config(format!( + "Refusing full workspace HUGR tests with {mode} LLVM at {}. \ + LLVM 21.1 static workspace tests can spawn many multi-GB linker jobs. \ + {setup_hint}", + llvm_path.display() + ))) +} + /// Run the rust subcommand pub fn run(command: &super::RustCommands) -> Result<()> { match command { @@ -202,7 +269,7 @@ fn is_tool_available(tool: &str) -> bool { /// Run a cargo command and return success status. /// -/// Applies the PECOS build environment (`CMAKE`, `LLVM_SYS_140_PREFIX`, +/// Applies the PECOS build environment (`CMAKE`, `LLVM_SYS_211_PREFIX`, /// `SDKROOT`, etc.) so build scripts like highs-sys's cmake-rs invocation /// find the PECOS-managed cmake without further plumbing. fn run_cargo_command(args: &[&str]) -> bool { @@ -457,9 +524,10 @@ fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { println!("Testing workspace packages..."); // runtime = sim + qasm + phir (format parsers) // hugr = qis (includes llvm) + hugr compilation + // neo = sim() routing to the pecos-neo stack (contract tests) // pecos-cli is excluded here and tested separately below with --features=runtime // to ensure the pecos binary has PHIR/QIS support for integration tests. - let mut args: Vec<&str> = vec!["test", "--workspace", "--features=runtime,hugr"]; + let mut args: Vec<&str> = vec!["test", "--workspace", "--features=runtime,hugr,neo"]; for crate_name in FFI_CRATES.iter().chain(PYO3_CDYLIB_TEST_EXCLUDES) { args.push("--exclude"); @@ -478,6 +546,7 @@ fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { ]); args.extend(profile_args); + reject_static_llvm_workspace_test()?; if !run(&args) { return Err(Error::Config("cargo test (workspace) failed".to_string())); @@ -497,6 +566,19 @@ fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { )); } + // Test zlup's CLI integration tests separately with --features=cli. The + // `zlup` binary has `required-features = ["cli"]`, so the default-feature + // workspace run above does not build it and skips tests/cli.rs entirely; + // this run exercises those CLI integration tests. + println!("Testing zlup with cli feature..."); + let mut zlup_args: Vec<&str> = vec!["test", "-p", "zlup", "--features=cli"]; + zlup_args.extend(profile_args); + if !run(&zlup_args) { + return Err(Error::Config( + "cargo test (zlup with cli) failed".to_string(), + )); + } + // Test cuQuantum if SDK is available (requires both CUDA and cuQuantum) if probe_cuquantum_availability() { println!("cuQuantum runtime available - testing pecos-cuquantum"); @@ -550,3 +632,24 @@ fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { println!("cargo test completed successfully"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn llvm_link_mode_parses_llvm_config_output() { + assert_eq!( + LlvmLinkMode::from_llvm_config("shared\n"), + LlvmLinkMode::Shared + ); + assert_eq!( + LlvmLinkMode::from_llvm_config("STATIC"), + LlvmLinkMode::Static + ); + assert_eq!( + LlvmLinkMode::from_llvm_config("unknown"), + LlvmLinkMode::Unknown + ); + } +} diff --git a/crates/pecos-cli/src/cli/setup_cmd.rs b/crates/pecos-cli/src/cli/setup_cmd.rs index a78c102d9..655da42f6 100644 --- a/crates/pecos-cli/src/cli/setup_cmd.rs +++ b/crates/pecos-cli/src/cli/setup_cmd.rs @@ -67,7 +67,7 @@ pub fn run( } fn has_missing_deps(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool) -> bool { - if !skip_llvm && pecos_build::llvm::find_llvm_14(None).is_none() { + if !skip_llvm && pecos_build::llvm::find_llvm(None).is_none() { return true; } if !skip_cuda && cuda_platform_supported() && pecos_build::cuda::find_cuda().is_none() { @@ -97,11 +97,15 @@ fn print_status_summary(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool) { // LLVM if skip_llvm { - println!(" LLVM 14: skipped (--skip-llvm)"); - } else if let Some(path) = pecos_build::llvm::find_llvm_14(None) { - println!(" LLVM 14: {}", path.display()); + println!(" LLVM 21.1: skipped (--skip-llvm)"); + } else if let Some(path) = pecos_build::llvm::find_llvm(None) { + println!(" LLVM 21.1: {}", path.display()); + } else if pecos_build::llvm::installer::managed_install_unavailable_reason().is_some() { + println!(" LLVM 21.1: not found (configure a shared LLVM 21 install manually)"); } else { - println!(" LLVM 14: not found (~400 MB, required for QIR/HUGR compilation)"); + println!( + " LLVM 21.1: not found (several hundred MB, required for QIR/HUGR compilation)" + ); } // CUDA @@ -250,13 +254,55 @@ fn find_repo_root() -> Option { // ── Migration ────────────────────────────────────────────────────────────── fn check_legacy_deps(mode: PromptMode) -> Result<()> { - let legacy = pecos_build::home::find_legacy_deps()?; - if legacy.is_empty() { + let legacy = pecos_build::home::find_legacy_dep_status()?; + if legacy.migratable.is_empty() && legacy.incompatible.is_empty() { + return Ok(()); + } + + if !legacy.incompatible.is_empty() { + println!("Found legacy dependencies that need manual action:"); + for dep in &legacy.incompatible { + println!(" {} at {}", dep.name, dep.old.display()); + println!(" {}", dep.reason); + if dep.name == "LLVM" { + println!(" This will not be migrated into ~/.pecos/deps/llvm-21.1/."); + println!(" Remove it before installing/configuring LLVM 21.1."); + println!(" Then install LLVM 21.1 with `pecos install llvm`, or configure"); + println!( + " an existing LLVM 21.1 install with `pecos llvm configure /path/to/llvm`." + ); + } + } + println!(); + + for dep in &legacy.incompatible { + if dep.name != "LLVM" { + continue; + } + if confirm( + &format!("Remove incompatible legacy LLVM at {}?", dep.old.display()), + true, + mode, + ) { + print!(" Removing old LLVM..."); + pecos_build::home::remove_incompatible_legacy_dep(dep)?; + println!(" done"); + } else { + println!( + " Keeping old LLVM at {}. It will not be used as LLVM 21.1.", + dep.old.display() + ); + } + } + println!(); + } + + if legacy.migratable.is_empty() { return Ok(()); } println!("Found dependencies at legacy paths:"); - for dep in &legacy { + for dep in &legacy.migratable { println!(" {} -> {}", dep.old.display(), dep.new.display()); } @@ -265,7 +311,7 @@ fn check_legacy_deps(mode: PromptMode) -> Result<()> { true, mode, ) { - for dep in &legacy { + for dep in &legacy.migratable { print!(" Moving {}...", dep.name); pecos_build::home::migrate_legacy_dep(dep)?; println!(" done"); @@ -282,14 +328,23 @@ fn check_legacy_deps(mode: PromptMode) -> Result<()> { // ── LLVM ──────────────────────────────────────────────────────────────────── fn setup_llvm(mode: PromptMode) -> Result<()> { - if pecos_build::llvm::find_llvm_14(None).is_some() { + if pecos_build::llvm::find_llvm(None).is_some() { ensure_llvm_configured(); return Ok(()); } + if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() { + println!(" LLVM 21.1 not found."); + println!(" {reason}"); + println!(" QIR/HUGR features will not be available until LLVM is configured."); + return Ok(()); + } + let version = pecos_build::home::LLVM_VERSION; if confirm( - &format!("Install LLVM {version}? (~400 MB download, required for QIR/HUGR)"), + &format!( + "Install PECOS-managed LLVM {version}? (large download, several GB extracted, shared LLVM required; use `pecos llvm configure /path/to/llvm` for your own install)" + ), true, mode, ) { diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index 7cbf4ad67..ac5f7dd94 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -24,7 +24,7 @@ use pecos_build::cuda::find_cuda; #[cfg(feature = "runtime")] use pecos_build::cuquantum::{find_cuquantum, get_cuquantum_version}; #[cfg(feature = "runtime")] -use pecos_build::llvm::get_llvm_version; +use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, get_llvm_version}; #[cfg(feature = "runtime")] use std::io::Write; @@ -97,7 +97,7 @@ enum Commands { #[command(subcommand)] command: GpuCommands, }, - /// LLVM 14 inspection, validation, and configuration + /// LLVM 21.1 inspection, validation, and configuration Llvm { #[command(subcommand)] command: LlvmCommands, @@ -168,8 +168,18 @@ enum Commands { /// Migrate legacy deps from ~/.pecos/ to ~/.pecos/deps/ /// /// Moves LLVM, CUDA, and cuQuantum installations from the old top-level - /// paths into the unified deps/ directory. - Migrate, + /// paths into the unified deps/ directory. Legacy LLVM installations that + /// are not valid LLVM 21.1 installs can be removed before installing the + /// current managed LLVM. + Migrate { + /// Accept all prompts without asking + #[arg(long, conflicts_with = "no")] + yes: bool, + + /// Decline all prompts without asking + #[arg(long, conflicts_with = "yes")] + no: bool, + }, /// Install optional dependencies (cuda, llvm, cuquantum) /// /// Example: pecos install cuda cuquantum @@ -189,6 +199,10 @@ enum Commands { /// Skip automatic configuration after installation (applies to llvm) #[arg(long)] no_configure: bool, + + /// Accept installation prompts + #[arg(short, long)] + yes: bool, }, /// Uninstall optional dependencies (cuda, llvm, cuquantum) /// @@ -722,13 +736,23 @@ fn main() -> Result<(), Box> { }; cli::setup_cmd::run(mode, *skip_llvm, *skip_cuda, *skip_cmake, *quiet)?; } - Commands::Migrate => cli::migrate_cmd::run()?, + Commands::Migrate { yes, no } => { + let mode = if *yes { + pecos_build::prompt::PromptMode::AcceptAll + } else if *no { + pecos_build::prompt::PromptMode::DeclineAll + } else { + pecos_build::prompt::PromptMode::Interactive + }; + cli::migrate_cmd::run(mode)?; + } Commands::Install { targets, force, all, no_configure, - } => cli::install_cmd::run(targets, *force, *all, *no_configure)?, + yes, + } => cli::install_cmd::run(targets, *force, *all, *no_configure, *yes)?, Commands::Uninstall { targets, all, yes } => { cli::uninstall_cmd::run(targets, *all, *yes)?; } @@ -903,8 +927,8 @@ fn run_doctor() { let mut problems: Vec = Vec::new(); let mut hints: Vec = Vec::new(); - // --- LLVM 14 --- - println!("LLVM 14:"); + // --- LLVM 21.1 --- + println!("LLVM 21.1:"); let llvm_config = pecos_build::llvm::config::validate_llvm_config(); if let Some(ref path) = llvm_config.detected_path { let version = get_llvm_version(path).unwrap_or_else(|_| "unknown".into()); @@ -915,11 +939,15 @@ fn run_doctor() { ); } else { print_check("installed", false, "not found"); - problems.push("LLVM 14 not installed. Run: pecos install llvm".into()); + if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() { + problems.push(format!("LLVM 21.1 not installed. {reason}")); + } else { + problems.push("LLVM 21.1 not installed. Run: pecos install llvm".into()); + } } if let Some(ref path) = llvm_config.configured_path { - if llvm_config.path_is_valid_llvm14 { + if llvm_config.path_is_valid_llvm { print_check(".cargo/config.toml", true, &format!("{}", path.display())); } else if !llvm_config.path_exists { print_check( @@ -932,32 +960,36 @@ fn run_doctor() { print_check( ".cargo/config.toml", false, - &format!("path exists but is not valid LLVM 14: {}", path.display()), + &format!("path exists but is not valid LLVM 21.1: {}", path.display()), ); problems.push( - "LLVM path in .cargo/config.toml is not valid LLVM 14. Run: pecos llvm configure" + "LLVM path in .cargo/config.toml is not valid LLVM 21.1. Run: pecos llvm configure" .into(), ); } } else { - print_check(".cargo/config.toml", false, "LLVM_SYS_140_PREFIX not set"); + print_check( + ".cargo/config.toml", + false, + &format!("{LLVM_SYS_PREFIX_ENV} not set"), + ); if llvm_config.detected_path.is_some() { problems.push("LLVM installed but not configured. Run: pecos llvm configure".into()); } } - if let Ok(env_val) = std::env::var("LLVM_SYS_140_PREFIX") { + if let Ok(env_val) = std::env::var(LLVM_SYS_PREFIX_ENV) { let env_path = std::path::Path::new(&env_val); if env_path.exists() { - print_check("LLVM_SYS_140_PREFIX env", true, &env_val); + print_check(&format!("{LLVM_SYS_PREFIX_ENV} env"), true, &env_val); } else { print_check( - "LLVM_SYS_140_PREFIX env", + &format!("{LLVM_SYS_PREFIX_ENV} env"), false, &format!("set but path missing: {env_val}"), ); problems.push(format!( - "LLVM_SYS_140_PREFIX={env_val} but path does not exist" + "{LLVM_SYS_PREFIX_ENV}={env_val} but path does not exist" )); } } diff --git a/crates/pecos-core/src/clifford_simplify.rs b/crates/pecos-core/src/clifford_simplify.rs index e3caf97e2..c8725f137 100644 --- a/crates/pecos-core/src/clifford_simplify.rs +++ b/crates/pecos-core/src/clifford_simplify.rs @@ -11,6 +11,11 @@ use crate::gate_type::GateType; /// Type alias -- all comparisons use 64-bit fixed-point angles. type A64 = Angle; +/// Numerical lowering pipelines can produce angles that are a few fixed-point +/// units away from canonical Clifford quarter-turns. Snap only within a tiny +/// tolerance so genuine non-Clifford rotations still fail loudly. +const R1XY_CLIFFORD_EPSILON_TURNS: f64 = 1e-9; + /// Eighth-turn (pi/4): `QUARTER_TURN` / 2. fn eighth_turn() -> A64 { A64::QUARTER_TURN / 2u64 @@ -94,10 +99,12 @@ pub fn try_simplify_rotation(gate: GateType, angle: A64) -> Option { /// quarter-turn sqrt gates. #[must_use] pub fn try_simplify_r1xy(theta: A64, phi: A64) -> Option { + let theta = snap_r1xy_clifford_angle(theta)?; if theta == A64::ZERO { return Some(GateType::I); } + let phi = snap_r1xy_clifford_angle(phi)?; match phi { A64::ZERO => simplify_rx(theta), A64::HALF_TURN => simplify_rx(-theta), @@ -111,6 +118,17 @@ pub fn try_simplify_r1xy(theta: A64, phi: A64) -> Option { // Internal helpers // ------------------------------------------------------------------------- +fn snap_r1xy_clifford_angle(angle: A64) -> Option { + [ + A64::ZERO, + A64::QUARTER_TURN, + A64::HALF_TURN, + A64::THREE_QUARTERS_TURN, + ] + .into_iter() + .find(|target| angle.abs_diff_eq_turns(target, R1XY_CLIFFORD_EPSILON_TURNS)) +} + /// Negate an angle. fn neg(a: A64) -> A64 { -a @@ -454,6 +472,17 @@ mod tests { ); } + #[test] + fn r1xy_near_quarter_turn_sqrt_gates() { + assert_eq!( + try_simplify_r1xy( + Angle64::from_turns(0.25 + 1e-12), + Angle64::from_turns(0.75 - 1e-12), + ), + Some(GateType::SYdg) + ); + } + #[test] fn r1xy_three_quarter_turn_sqrt_dagger_gates() { // theta=3pi/2, phi=0: SXdg diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index 63c532ca8..9e0b6845f 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -193,6 +193,12 @@ impl GateType { /// /// Meta-gates have a position in the DAG but do not affect quantum state /// and should not create fault locations or receive noise. + /// + /// Idle-duration accounting depends on this predicate: + /// `TickCircuit::fill_idle_gates` treats ticks whose batches are all + /// meta as zero physical duration. Any new meta gate type MUST be added + /// here, or it will manufacture phantom idle periods in + /// idle-duration-driven noise models. #[must_use] pub const fn is_meta(self) -> bool { matches!(self, GateType::TrackedPauliMeta) @@ -271,8 +277,9 @@ impl GateType { /// /// # Returns /// - /// The number of qubits this gate type requires. All current gate types - /// have a fixed number of qubits (1 or 2). + /// The number of qubits this gate type requires. Variable-arity + /// payload/meta gates return 1 for compatibility with validation code; the + /// concrete gate stores the actual qubit count. #[must_use] pub const fn quantum_arity(self) -> usize { match self { @@ -304,13 +311,12 @@ impl GateType { | GateType::QAlloc | GateType::QFree | GateType::Idle + | GateType::Custom + // Payload/meta gates are variable-arity but return 1 here because + // validation checks `is_multiple_of(quantum_arity())`, and any + // count is a multiple of 1. The actual qubit count is in the gate. | GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload - | GateType::Custom - // TrackedPauliMeta and Channel are variable-arity but return 1 - // here because gate validation checks - // `is_multiple_of(quantum_arity())` and any count is a multiple - // of 1. The actual qubit count is in the gate. | GateType::Channel | GateType::TrackedPauliMeta => 1, @@ -527,6 +533,12 @@ impl std::str::FromStr for GateType { "QFREE" => Ok(GateType::QFree), "IDLE" => Ok(GateType::Idle), "TRACKEDPAULI" | "TRACKEDPAULIMETA" | "TP" => Ok(GateType::TrackedPauliMeta), + "MEASCROSSTALKGLOBALPAYLOAD" | "MEAS_CROSSTALK_GLOBAL_PAYLOAD" => { + Ok(GateType::MeasCrosstalkGlobalPayload) + } + "MEASCROSSTALKLOCALPAYLOAD" | "MEAS_CROSSTALK_LOCAL_PAYLOAD" => { + Ok(GateType::MeasCrosstalkLocalPayload) + } "CHANNEL" => Ok(GateType::Channel), _ => Err(format!("Unknown gate type: {s}")), } @@ -611,6 +623,14 @@ mod tests { assert_eq!(GateType::from_str("Channel").unwrap(), GateType::Channel); assert_eq!(GateType::from_str("SWAP").unwrap(), GateType::SWAP); assert_eq!(GateType::from_str("CCX").unwrap(), GateType::CCX); + assert_eq!( + GateType::from_str("MeasCrosstalkGlobalPayload").unwrap(), + GateType::MeasCrosstalkGlobalPayload + ); + assert_eq!( + GateType::from_str("MeasCrosstalkLocalPayload").unwrap(), + GateType::MeasCrosstalkLocalPayload + ); // Aliases assert_eq!(GateType::from_str("CNOT").unwrap(), GateType::CX); @@ -618,6 +638,14 @@ mod tests { assert_eq!(GateType::from_str("S").unwrap(), GateType::SZ); assert_eq!(GateType::from_str("TOFFOLI").unwrap(), GateType::CCX); assert_eq!(GateType::from_str("init |0>").unwrap(), GateType::PZ); + assert_eq!( + GateType::from_str("meas_crosstalk_global_payload").unwrap(), + GateType::MeasCrosstalkGlobalPayload + ); + assert_eq!( + GateType::from_str("meas_crosstalk_local_payload").unwrap(), + GateType::MeasCrosstalkLocalPayload + ); // Case-insensitive matching assert_eq!(GateType::from_str("h").unwrap(), GateType::H); diff --git a/crates/pecos-decoder-core/Cargo.toml b/crates/pecos-decoder-core/Cargo.toml index 0b1031d92..e06b6f8a3 100644 --- a/crates/pecos-decoder-core/Cargo.toml +++ b/crates/pecos-decoder-core/Cargo.toml @@ -17,6 +17,7 @@ thiserror.workspace = true anyhow.workspace = true pecos-random.workspace = true rayon.workspace = true +smallvec.workspace = true [lints] workspace = true diff --git a/crates/pecos-decoder-core/src/adaptive.rs b/crates/pecos-decoder-core/src/adaptive.rs index c05d5cc18..1e2e280e3 100644 --- a/crates/pecos-decoder-core/src/adaptive.rs +++ b/crates/pecos-decoder-core/src/adaptive.rs @@ -139,8 +139,8 @@ impl AdaptiveDecoder { } impl ObservableDecoder for AdaptiveDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - self.decoder.decode_to_observables(syndrome) + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + self.decoder.decode_obs(syndrome) } } @@ -153,8 +153,11 @@ mod tests { let dec = AdaptiveDecoder::new("error(0.1) D0\n", |_dem| { struct Zero; impl ObservableDecoder for Zero { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(0) + fn decode_obs( + &mut self, + _: &[u8], + ) -> Result { + Ok(crate::obs_mask::ObsMask::new()) } } Ok(Box::new(Zero)) @@ -170,8 +173,11 @@ mod tests { let mut dec = AdaptiveDecoder::new("error(0.1) D0\n", |_dem| { struct Zero; impl ObservableDecoder for Zero { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(0) + fn decode_obs( + &mut self, + _: &[u8], + ) -> Result { + Ok(crate::obs_mask::ObsMask::new()) } } Ok(Box::new(Zero)) diff --git a/crates/pecos-decoder-core/src/bp_matching.rs b/crates/pecos-decoder-core/src/bp_matching.rs index 7e8f6477f..89198658d 100644 --- a/crates/pecos-decoder-core/src/bp_matching.rs +++ b/crates/pecos-decoder-core/src/bp_matching.rs @@ -79,7 +79,7 @@ impl BpMatchingDecoder { } impl crate::ObservableDecoder for BpMatchingDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { // Predecoder fast path: only for zero-defect syndromes. // At d>=5, always use full MWPM (predecoder can be suboptimal). // At d=3, BP + predecoder is actually better than MWPM for simple @@ -87,7 +87,7 @@ impl crate::ObservableDecoder for BpMat // predecoder and let MWPM handle everything for consistency. let num_defects = syndrome.iter().filter(|&&v| v != 0).count(); if num_defects == 0 { - return Ok(0); + return Ok(crate::obs_mask::ObsMask::new()); } // Compute BP-adjusted weights. @@ -117,11 +117,11 @@ impl crate::ObservableDecoder for BpMat let (obs, _) = self .matching .decode_with_weights(syndrome, &self.adjusted_weights)?; - return Ok(obs); + return Ok(crate::obs_mask::ObsMask::from_u64(obs)); } // Single-pass belief-matching. let (obs, _) = self.matching.decode_with_weights(syndrome, &bp_weights)?; - Ok(obs) + Ok(crate::obs_mask::ObsMask::from_u64(obs)) } } diff --git a/crates/pecos-decoder-core/src/correlated_decoder.rs b/crates/pecos-decoder-core/src/correlated_decoder.rs index 4c3f81e4c..167c5bc9c 100644 --- a/crates/pecos-decoder-core/src/correlated_decoder.rs +++ b/crates/pecos-decoder-core/src/correlated_decoder.rs @@ -136,7 +136,7 @@ impl CorrelatedDecoder { } impl crate::ObservableDecoder for CorrelatedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { self.shots_decoded += 1; // During training: decode normally, record matchings @@ -149,7 +149,7 @@ impl crate::ObservableDecoder for CorrelatedDecoder { self.base_weights = self.tracker.aligned_weights(); } - return Ok(mask); + return Ok(crate::obs_mask::ObsMask::from_u64(mask)); } // After training: two-pass decode with correlation adjustment @@ -163,7 +163,7 @@ impl crate::ObservableDecoder for CorrelatedDecoder { self.tracker.record_matching(&first_matching); if !self.config.use_correlation { - return Ok(first_mask); + return Ok(crate::obs_mask::ObsMask::from_u64(first_mask)); } // Build matched-edge flags for correlation adjustment @@ -184,7 +184,7 @@ impl crate::ObservableDecoder for CorrelatedDecoder { .inner .decode_with_weights(syndrome, &adjusted_weights)?; - Ok(second_mask) + Ok(crate::obs_mask::ObsMask::from_u64(second_mask)) } } diff --git a/crates/pecos-decoder-core/src/decode_budget.rs b/crates/pecos-decoder-core/src/decode_budget.rs index 140b89582..34a194c7c 100644 --- a/crates/pecos-decoder-core/src/decode_budget.rs +++ b/crates/pecos-decoder-core/src/decode_budget.rs @@ -188,6 +188,17 @@ pub trait DecodeStrategy: Send + Sync { /// which portion to decode based on its internal state and budget. fn decode(&mut self, syndrome: &[u8]) -> Result; + /// Decode and return a wide [`ObsMask`](crate::obs_mask::ObsMask) (supports + /// more than 64 observables). Default-bridges [`Self::decode`]; strategies + /// that aggregate more than 64 observables override this. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if decoding fails. + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.decode(syndrome)?)) + } + /// Commit corrections for a detector region. /// /// After commitment, detectors in this region are excluded from diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index 324a21e50..99e3fe233 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -103,7 +103,11 @@ pub mod utils { /// Returns [`DecoderError`] if the DEM format is invalid pub fn parse_dem_metadata(dem: &str) -> Result<(usize, usize), DecoderError> { let mut max_detector = None; - let mut observables = std::collections::BTreeSet::new(); + // Count as `max index + 1` (not distinct-id count) to match the other + // parsers (`SparseDem`, `DemCheckMatrix`, `DemMatchingGraph`) and to size + // index-addressed buffers correctly when ids are non-contiguous + // (e.g. only `L2` present -> 3 observables, not 1). + let mut max_observable: Option = None; for line in dem.lines() { let line = line.trim(); @@ -111,21 +115,38 @@ pub mod utils { continue; } + // This is a flat single-pass counter; reject loop/offset commands + // rather than miscounting them (same contract as the other parsers). + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "parse_dem_metadata requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } + let parts: Vec<&str> = line.split_whitespace().collect(); if parts.is_empty() { continue; } - // Handle commands with probability parameters like "error(0.01)" + // Normalize commands that carry parenthesized parameters: + // `error(0.01) ...` and `detector(x,y,t) Dk`. Stim emits bare + // `detector Dk` for declarations without coordinates; that form + // already matches via `parts[0]` below. let command = if parts[0].starts_with("error(") { "error" + } else if parts[0].starts_with("detector(") { + "detector" } else { parts[0] }; match command { - "error" => { - // Parse error line for detector and observable indices + // `error` and `logical_observable` both contribute observable + // ids; `logical_observable` declares deterministic logicals that + // Stim emits with no flipping mechanism but still count. + "error" | "logical_observable" => { for part in &parts[1..] { if let Some(d_str) = part.strip_prefix('D') { if let Ok(d) = d_str.parse::() { @@ -134,7 +155,7 @@ pub mod utils { } else if let Some(l_str) = part.strip_prefix('L') && let Ok(l) = l_str.parse::() { - observables.insert(l); + max_observable = Some(max_observable.map_or(l, |m: usize| m.max(l))); } } } @@ -153,7 +174,7 @@ pub mod utils { } let detector_count = max_detector.map_or(0, |m| m + 1); - let observable_count = observables.len(); + let observable_count = max_observable.map_or(0, |m| m + 1); Ok((detector_count, observable_count)) } @@ -200,6 +221,189 @@ pub mod utils { } } +/// Sparse parse of a DEM: the error mechanisms plus detector coordinates, +/// without the dense matrices of [`DemCheckMatrix`]. +/// +/// Each `error(p) ...` line becomes one `(probability, detector_ids, +/// observable_ids)` entry. Decomposed mechanisms (`D0 ^ D1`) are XOR-combined; +/// graphlike mechanisms keep their DEM token order. `detector(x, y, t) D_i` +/// declarations are collected into `detector_coords`. +/// +/// Parsing runs once at decoder construction (never in a decode hot loop), so +/// this is plain line-based parsing — a byte-level variant was profiled and +/// gave no measurable speedup over this. +/// +/// # Example +/// +/// ``` +/// use pecos_decoder_core::dem::SparseDem; +/// +/// let dem = "detector(1, 0, 0) D0\nerror(0.01) D0 D1 L0\nerror(0.02) D1"; +/// let sdem = SparseDem::from_dem_str(dem).unwrap(); +/// assert_eq!(sdem.num_detectors, 2); +/// assert_eq!(sdem.num_observables, 1); +/// assert_eq!(sdem.mechanisms.len(), 2); +/// ``` +#[derive(Debug, Clone)] +pub struct SparseDem { + /// Per-mechanism: `(probability, detector_ids, observable_ids)`. + pub mechanisms: Vec<(f64, Vec, Vec)>, + /// Detector id → coordinates (spatial + time), from `detector(...)` lines. + pub detector_coords: std::collections::BTreeMap>, + /// Number of detectors: max detector id + 1, across both mechanisms and + /// `detector(...)` declarations (0 if none). + pub num_detectors: usize, + /// Number of observables: max observable id + 1 (0 if none). + pub num_observables: usize, +} + +impl SparseDem { + /// Parse a DEM string into its sparse mechanism + coordinate form. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if an `error(...)` line is malformed. + pub fn from_dem_str(dem: &str) -> Result { + let mut mechanisms: Vec<(f64, Vec, Vec)> = Vec::new(); + let mut detector_coords = std::collections::BTreeMap::new(); + let mut max_detector: Option = None; + let mut max_observable: Option = None; + + for line in dem.lines() { + let line = line.trim(); + + // This is a flat, single-pass parser: it does not expand `repeat` + // blocks or apply `shift_detectors`. Silently mis-parsing those would + // corrupt detector ids, so refuse them and tell the caller to flatten + // (e.g. stim's `DetectorErrorModel.flattened()`). + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "SparseDem requires a flattened DEM: `repeat` / `shift_detectors` \ + are not supported. Flatten the DEM first (e.g. stim's \ + DetectorErrorModel.flattened())." + .into(), + )); + } + + if let Some(rest) = line.strip_prefix("error(") { + let close = rest.find(')').ok_or_else(|| { + DecoderError::InvalidConfiguration("Missing ) in error line".into()) + })?; + let probability: f64 = rest[..close].parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid probability: {}", + &rest[..close] + )) + })?; + let tokens = &rest[close + 1..]; + + let (detectors, observables) = if tokens.contains('^') { + // Decomposed mechanism: XOR-combine components into sorted sets. + let mut det_set = std::collections::BTreeSet::new(); + let mut obs_set = std::collections::BTreeSet::new(); + for token in tokens.split('^').flat_map(str::split_whitespace) { + // Reject a malformed `D` / `L` token rather than + // silently dropping it -- matches DemCheckMatrix / + // DemMatchingGraph so all parsers agree on what is valid. + if let Some(d_str) = token.strip_prefix('D') { + let d: u32 = d_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid detector: {token}" + )) + })?; + if !det_set.remove(&d) { + det_set.insert(d); + } + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } else if let Some(l_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))); + } + } + (det_set.into_iter().collect(), obs_set.into_iter().collect()) + } else { + // Graphlike mechanism: keep DEM token order. + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + for token in tokens.split_whitespace() { + // Reject malformed `D` / `L` (parser-agreement + // contract, see the decomposed branch above). + if let Some(d_str) = token.strip_prefix('D') { + let d: u32 = d_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid detector: {token}" + )) + })?; + detectors.push(d); + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } else if let Some(l_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))); + } + } + (detectors, observables) + }; + + mechanisms.push((probability, detectors, observables)); + } else if let Some(rest) = line.strip_prefix("detector") { + // `detector(x,y,t) Dk` carries coordinates; Stim emits bare + // `detector Dk` for declarations without coordinates. Both + // declare the id, which counts toward `num_detectors` even if + // no error mechanism references it. + let (coords, targets) = if let Some(after) = rest.strip_prefix('(') { + let Some(close) = after.find(')') else { + continue; + }; + let coords: Vec = after[..close] + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + (Some(coords), &after[close + 1..]) + } else { + (None, rest) + }; + for token in targets.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + if let Some(c) = &coords { + detector_coords.insert(d as usize, c.clone()); + } + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + } else if let Some(rest) = line.strip_prefix("logical_observable") { + // Stim emits `logical_observable Lk` for observables that no + // error mechanism flips (deterministic / unflipped logicals). + // Honour the declared count so a trailing unflipped observable + // is not silently dropped from `num_observables`. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + } + } + + Ok(Self { + mechanisms, + detector_coords, + num_detectors: max_detector.map_or(0, |m| m as usize + 1), + num_observables: max_observable.map_or(0, |m| m as usize + 1), + }) + } +} + /// Check matrix representation extracted from a Detector Error Model. /// /// Converts a DEM string into the matrices needed by check-matrix-based @@ -259,8 +463,45 @@ impl DemCheckMatrix { if line.is_empty() || line.starts_with('#') { continue; } + if let Some(rest) = line.strip_prefix("logical_observable") { + // Count declared observables that no mechanism flips. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + continue; + } + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "DemCheckMatrix requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } + if let Some(rest) = line.strip_prefix("detector") { + // Count the declared detector id, which may not be referenced by + // any error mechanism. Stim emits `detector(x,y,t) Dk` when + // coordinates are attached and bare `detector Dk` when not; both + // declare the id. All parsers agree on + // `max(declared, error-referenced) + 1`. + let targets = if let Some(after) = rest.strip_prefix('(') { + match after.find(')') { + Some(close) => &after[close + 1..], + None => continue, + } + } else { + rest + }; + for token in targets.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + continue; + } if !line.starts_with("error(") { - // Skip non-error lines (detector, logical_observable, etc.) + // Skip other non-error lines (logical_observable handled above). continue; } @@ -340,6 +581,27 @@ impl DemCheckMatrix { }) } + /// Hyperedge matching decoders (MWPF, A* on the full DEM) pack observable + /// flips into a `u64` (`1 << observable`), so they support at most 64 + /// observables. Returns an error (rather than letting construction + /// overflow-panic on `1 << o` for `o >= 64`) if this DEM exceeds that, + /// directing callers to a wide decoder. + /// + /// # Errors + /// + /// Returns [`DecoderError::InvalidConfiguration`] if `num_observables > 64`. + pub fn ensure_observables_fit_u64(&self) -> Result<(), DecoderError> { + if self.num_observables > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "this matching decoder packs observables into a u64 and supports at most 64 \ + observables, but the DEM has {}; use the 'pymatching' decoder or \ + LogicalSubgraphDecoder for wider observable sets", + self.num_observables + ))); + } + Ok(()) + } + /// Compute the observable prediction from a correction vector. /// /// Given a binary correction vector (one entry per mechanism, from a @@ -374,6 +636,26 @@ impl DemCheckMatrix { } mask } + + /// Pack observable predictions into an [`ObsMask`](crate::obs_mask::ObsMask). + /// + /// Bit `i` is set if observable `i` is predicted to flip. Unlike + /// [`Self::observables_mask_from_correction`], this supports more than 64 + /// observables without truncation or overflow. + #[must_use] + pub fn observables_obsmask_from_correction( + &self, + correction: &[u8], + ) -> crate::obs_mask::ObsMask { + let obs = self.observables_from_correction(correction); + let mut mask = crate::obs_mask::ObsMask::new(); + for (i, &v) in obs.iter().enumerate() { + if v != 0 { + mask.set(i); + } + } + mask + } } /// An edge in a matching graph extracted from a DEM. @@ -441,6 +723,42 @@ impl DemMatchingGraph { for line in dem.lines() { let line = line.trim(); + if let Some(rest) = line.strip_prefix("logical_observable") { + // Count declared observables that no mechanism flips. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + continue; + } + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "DemMatchingGraph requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } + if let Some(rest) = line.strip_prefix("detector") { + // Count the declared detector id (may not be error-referenced) so + // `num_detectors` matches the other parsers and its coordinate is + // not later dropped from `detector_coords`. Stim emits bare + // `detector Dk` (no parentheses) for coordinate-less declarations. + let targets = if let Some(after) = rest.strip_prefix('(') { + match after.find(')') { + Some(close) => &after[close + 1..], + None => continue, + } + } else { + rest + }; + for token in targets.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + continue; + } if line.is_empty() || line.starts_with('#') || !line.starts_with("error(") { continue; } @@ -544,6 +862,29 @@ impl DemMatchingGraph { }) } + /// Matching decoders pack observable flips into a `u64` (`1 << observable`), + /// so they support at most 64 observables. Returns an error (rather than + /// letting construction overflow-panic on `1 << o` for `o >= 64`) if this + /// graph exceeds that, directing callers to a wide decoder. + /// + /// Call this at the start of any matching-decoder construction that enters + /// the `1 << o` packing loop, on untrusted DEM input. + /// + /// # Errors + /// + /// Returns [`DecoderError::InvalidConfiguration`] if `num_observables > 64`. + pub fn ensure_observables_fit_u64(&self) -> Result<(), DecoderError> { + if self.num_observables > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "this matching decoder packs observables into a u64 and supports at most 64 \ + observables, but the DEM has {}; use the 'pymatching' decoder or \ + LogicalSubgraphDecoder for wider observable sets", + self.num_observables + ))); + } + Ok(()) + } + /// Merge edges with independent fault-ID-aware probability combination. /// /// Components from the same fault mechanism (same `fault_id`) that land on @@ -700,7 +1041,7 @@ impl super::ObservableDecoder for CheckMatrixObservableDecoder where D: super::Decoder, { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { use super::DecodingResultTrait; // Copy syndrome into reusable buffer (no allocation after first call) @@ -718,7 +1059,7 @@ where .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; let correction = result.correction(); - Ok(self.dem.observables_mask_from_correction(correction)) + Ok(self.dem.observables_obsmask_from_correction(correction)) } } @@ -866,6 +1207,115 @@ mod tests { assert_eq!(observables, 2); // L0 and L1 } + #[test] + fn test_logical_observable_declaration_counts() { + // L1 has no flipping mechanism; Stim emits `logical_observable L1`. + // All parsers must still count it so the trailing observable is not + // silently dropped. + let dem = "error(0.01) D0 L0\ndetector(0, 0, 0) D0\nlogical_observable L1\n"; + + let sdem = SparseDem::from_dem_str(dem).unwrap(); + assert_eq!(sdem.num_observables, 2, "SparseDem must count L1"); + + let dcm = DemCheckMatrix::from_dem_str(dem).unwrap(); + assert_eq!(dcm.num_observables, 2, "DemCheckMatrix must count L1"); + + let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); + assert_eq!(graph.num_observables, 2, "DemMatchingGraph must count L1"); + + let (_dets, obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!(obs, 2, "parse_dem_metadata must count L1"); + } + + #[test] + fn test_parsers_count_declared_but_unreferenced_detectors() { + // A detector declared via `detector(coords) Dk` but never referenced by + // an error mechanism must still count (max declared id + 1). All four + // parsers must agree; index-addressed buffers depend on it. + let dem = "detector(0, 0, 0) D2\nlogical_observable L0\n"; + assert_eq!(SparseDem::from_dem_str(dem).unwrap().num_detectors, 3); + assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_detectors, 3); + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, + 3 + ); + let (dets, _obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!( + dets, 3, + "parse_dem_metadata must count declared detector D2" + ); + } + + #[test] + fn test_parsers_count_bare_detector_declarations() { + // Stim emits coordinate-less declarations as bare `detector Dk` (no + // parentheses). All four parsers must count it like the parenthesized + // form; three of them previously gated on `detector(` and dropped it. + let dem = "error(0.01) D0 L0\ndetector D7\n"; + assert_eq!(SparseDem::from_dem_str(dem).unwrap().num_detectors, 8); + assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_detectors, 8); + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, + 8 + ); + let (dets, _obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!(dets, 8, "parse_dem_metadata must count bare detector D7"); + } + + #[test] + fn test_parsers_reject_malformed_detector_token() { + // A `D` / `L` token in an error line is malformed. All three + // error-line parsers must reject it (not silently drop it) so they agree + // on what a valid DEM is. SparseDem previously skipped these silently. + let bad_det = "error(0.01) Dfoo L0\n"; + assert!(SparseDem::from_dem_str(bad_det).is_err()); + assert!(DemCheckMatrix::from_dem_str(bad_det).is_err()); + assert!(DemMatchingGraph::from_dem_str(bad_det).is_err()); + + let bad_obs = "error(0.01) D0 Lbar\n"; + assert!(SparseDem::from_dem_str(bad_obs).is_err()); + assert!(DemCheckMatrix::from_dem_str(bad_obs).is_err()); + assert!(DemMatchingGraph::from_dem_str(bad_obs).is_err()); + + // A well-formed line still parses. + assert!(SparseDem::from_dem_str("error(0.01) D0 D1 L0\n").is_ok()); + } + + #[test] + fn test_parser_observable_count_is_max_plus_one_for_noncontiguous_ids() { + // Only L2 present: index-addressed buffers need 3 slots, not 1. All + // parsers must agree on `max + 1`, not distinct-id count. + let dem = "error(0.01) D0 L2\ndetector(0,0,0) D0\n"; + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_observables, + 3 + ); + assert_eq!( + DemCheckMatrix::from_dem_str(dem).unwrap().num_observables, + 3 + ); + let (_d, obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!( + obs, 3, + "parse_dem_metadata must agree (max+1, not distinct count)" + ); + } + + #[test] + fn test_non_flattened_dem_rejected() { + // repeat blocks and shift_detectors would corrupt detector ids if parsed + // line-by-line; all parsers must refuse rather than silently mis-parse. + let repeat_dem = "repeat 3 {\n error(0.01) D0 L0\n shift_detectors 1\n}\n"; + assert!(SparseDem::from_dem_str(repeat_dem).is_err()); + assert!(DemCheckMatrix::from_dem_str(repeat_dem).is_err()); + assert!(DemMatchingGraph::from_dem_str(repeat_dem).is_err()); + assert!(utils::parse_dem_metadata(repeat_dem).is_err()); + + let shift_dem = "error(0.01) D0 L0\nshift_detectors 1\nerror(0.01) D0 L0\n"; + assert!(SparseDem::from_dem_str(shift_dem).is_err()); + assert!(utils::parse_dem_metadata(shift_dem).is_err()); + } + #[test] 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"; diff --git a/crates/pecos-decoder-core/src/ensemble.rs b/crates/pecos-decoder-core/src/ensemble.rs index c969f22c3..a2a3fa0b4 100644 --- a/crates/pecos-decoder-core/src/ensemble.rs +++ b/crates/pecos-decoder-core/src/ensemble.rs @@ -87,9 +87,9 @@ impl EnsembleDecoder { } impl ObservableDecoder for EnsembleDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { if self.decoders.is_empty() { - return Ok(0); + return Ok(crate::obs_mask::ObsMask::new()); } // Collect predictions from all decoders. @@ -135,7 +135,7 @@ impl ObservableDecoder for EnsembleDecoder { } } - Ok(result) + Ok(crate::obs_mask::ObsMask::from_u64(result)) } } @@ -168,11 +168,11 @@ impl ParallelEnsembleDecoder { } impl ObservableDecoder for ParallelEnsembleDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { use rayon::prelude::*; if self.decoders.is_empty() { - return Ok(0); + return Ok(crate::obs_mask::ObsMask::new()); } // Decode all members in parallel. @@ -193,7 +193,7 @@ impl ObservableDecoder for ParallelEnsembleDecoder { result |= mask; } } - Ok(result) + Ok(crate::obs_mask::ObsMask::from_u64(result)) } } @@ -205,8 +205,11 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _syndrome: &[u8]) -> Result { - Ok(self.0) + fn decode_obs( + &mut self, + _syndrome: &[u8], + ) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/errors.rs b/crates/pecos-decoder-core/src/errors.rs index d6eff425d..017d83fc4 100644 --- a/crates/pecos-decoder-core/src/errors.rs +++ b/crates/pecos-decoder-core/src/errors.rs @@ -48,6 +48,12 @@ pub enum DecoderError { #[error("Invalid node index {index}: must be < {max}")] InvalidNodeIndex { index: usize, max: usize }, + /// Observable-frame bit index out of range for the `u64` frame + #[error( + "Observable frame bit {bit} out of range: boundary-gate bits index a u64 frame and must be < 64" + )] + ObservableBitOutOfRange { bit: u32 }, + /// Invalid edge #[error("Invalid edge: {0}")] InvalidEdge(String), diff --git a/crates/pecos-decoder-core/src/ghost_protocol.rs b/crates/pecos-decoder-core/src/ghost_protocol.rs index 44cba87cc..99cca51d3 100644 --- a/crates/pecos-decoder-core/src/ghost_protocol.rs +++ b/crates/pecos-decoder-core/src/ghost_protocol.rs @@ -135,9 +135,9 @@ pub struct GhostMessage { #[must_use] pub fn extract_ghost_edges_from_dem( dem_str: &str, - stab_coords: &crate::observable_subgraph::StabCoords, + stab_coords: &crate::logical_subgraph::StabCoords, ) -> Vec { - use crate::observable_subgraph::classify_detector; + use crate::logical_subgraph::classify_detector; use std::collections::BTreeMap; // Parse detector coordinates @@ -263,7 +263,7 @@ mod tests { #[test] fn test_extract_ghost_edges_from_synthetic_dem() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; // Two qubits: qubit 0 has X-stab at (1,1) and Z-stab at (3,1), // qubit 1 has X-stab at (7,1) and Z-stab at (9,1). @@ -309,7 +309,7 @@ mod tests { #[test] fn test_extract_no_ghost_edges_graphlike_dem() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; let stab_coords = vec![QubitStabCoords { x_positions: vec![(1.0, 1.0)], @@ -329,7 +329,7 @@ mod tests { #[test] fn test_extract_three_same_qubit_no_ghost() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; let stab_coords = vec![QubitStabCoords { x_positions: vec![(1.0, 1.0), (1.0, 3.0)], diff --git a/crates/pecos-decoder-core/src/k_mwpm.rs b/crates/pecos-decoder-core/src/k_mwpm.rs index 7814059f7..a5fe2bb52 100644 --- a/crates/pecos-decoder-core/src/k_mwpm.rs +++ b/crates/pecos-decoder-core/src/k_mwpm.rs @@ -72,14 +72,14 @@ impl KMwpmDecoder { } impl ObservableDecoder for KMwpmDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { let k = self.config.k; // First matching: standard MWPM. let (obs1, edges1) = self.decoder.decode_with_matching(syndrome)?; if edges1.is_empty() { - return Ok(obs1); + return Ok(crate::obs_mask::ObsMask::from_u64(obs1)); } // Collect K matchings via decoding tree. @@ -207,7 +207,7 @@ impl ObservableDecoder for KMwpmDecoder { result |= mask; } } - Ok(result) + Ok(crate::obs_mask::ObsMask::from_u64(result)) } } diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index 6ac5e2bba..6e8054505 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -25,7 +25,6 @@ pub mod adaptive; pub mod advanced; pub mod bp_matching; -pub mod committed_osd; pub mod config; pub mod correlated_decoder; pub mod correlated_reweighting; @@ -38,9 +37,10 @@ pub mod errors; pub mod ghost_protocol; pub mod k_mwpm; pub mod logical_algorithm; +pub mod logical_subgraph; pub mod matrix; pub mod multi_decoder; -pub mod observable_subgraph; +pub mod obs_mask; pub mod pauli_frame; pub mod perturbed; pub mod preprocessor; @@ -48,7 +48,6 @@ pub mod results; pub mod streaming; pub mod telemetry; pub mod two_pass_decoder; -pub mod windowed_osd; use ndarray::ArrayView1; @@ -169,14 +168,35 @@ pub trait BatchDecoder: Decoder { /// orchestrator needs -- it doesn't care about decoder internals, weights, /// convergence, or matched edges. pub trait ObservableDecoder { - /// Decode a dense syndrome and return predicted observable flips as a bitmask. + /// Decode a dense syndrome and return predicted observable flips as a wide + /// [`ObsMask`](crate::obs_mask::ObsMask). /// - /// Bit `i` of the returned value is 1 if observable `i` is predicted to flip. + /// Bit `i` of the mask is set if observable `i` is predicted to flip. This is + /// the primitive every decoder implements; it carries one inline stack word + /// for the common `<= 64`-observable case and spills to the heap only beyond, + /// so more than 64 observables are supported with no truncation. /// /// # Errors /// /// Returns [`DecoderError`] if decoding fails. - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result; + fn decode_obs(&mut self, syndrome: &[u8]) -> Result; + + /// Narrowing convenience over [`Self::decode_obs`]: the predicted observable + /// flips packed into a `u64` (bit `i` = observable `i`). + /// + /// Errors (rather than truncating) if the decoder has more than 64 + /// observables; callers that may exceed 64 should use [`Self::decode_obs`]. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if decoding fails or the mask exceeds 64 observables. + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + ) + }) + } /// Batch decode: flat buffer of `num_shots × num_detectors` bytes. /// Returns one `u64` observable mask per shot. diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index cb97a7113..5a8e4f685 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -18,13 +18,14 @@ //! //! # Decoding Modes //! -//! - **Full-circuit**: Uses the full DEM's OSD for maximum accuracy. -//! Equivalent to `ObservableSubgraphDecoder` on the full circuit. +//! - **Full-circuit**: Uses the full DEM's logical-subgraph decoder for maximum accuracy. +//! Equivalent to `LogicalSubgraphDecoder` on the full circuit. //! - **Per-segment** (future streaming): Each segment decoded independently //! with buffer overlap at gate boundaries. use crate::ObservableDecoder; use crate::errors::DecoderError; +use crate::obs_mask::ObsMask; /// One segment of a logical algorithm. pub struct SegmentDescriptor { @@ -77,6 +78,32 @@ impl BoundaryGate { pub fn is_decision_point(&self) -> bool { matches!(self, Self::TGateInjection { .. }) } + + /// All observable-frame bit indices this gate references. Each must be < 64 + /// (they index a `u64` frame). Used to assert that invariant at apply time. + #[must_use] + pub fn obs_bits(&self) -> Vec { + match self { + Self::Hadamard { + x_obs_bit, + z_obs_bit, + } + | Self::SGate { + x_obs_bit, + z_obs_bit, + } => vec![*x_obs_bit, *z_obs_bit], + Self::Cnot { + ctrl_x_bit, + ctrl_z_bit, + tgt_x_bit, + tgt_z_bit, + } => vec![*ctrl_x_bit, *ctrl_z_bit, *tgt_x_bit, *tgt_z_bit], + Self::TGateInjection { + z_obs_bit, + ancilla_z_bit, + } => vec![*z_obs_bit, *ancilla_z_bit], + } + } } /// Full description of a logical algorithm for decoding. @@ -91,17 +118,17 @@ pub struct AlgorithmDescriptor { /// Decoder for logical quantum algorithms. /// -/// Wraps a full-circuit decoder (OSD) with segment metadata. The +/// Wraps a full-circuit decoder (logical-subgraph decoder) with segment metadata. The /// segment structure enables: /// - Tracking which gates occur at which point in the circuit /// - Pauli frame propagation for T-gate/measurement corrections /// - Future streaming mode with per-segment windowed decoding /// /// In the current implementation, `decode_shot` delegates to the -/// full-circuit OSD for maximum accuracy. The segment structure is +/// full-circuit logical-subgraph decoder for maximum accuracy. The segment structure is /// metadata for frame tracking and streaming (step 5). pub struct LogicalAlgorithmDecoder { - /// Full-circuit decoder (OSD on the complete DEM). + /// Full-circuit decoder (logical-subgraph decoder on the complete DEM). full_decoder: Box, /// Segment metadata for streaming/frame tracking. segments: Vec, @@ -114,7 +141,7 @@ pub struct LogicalAlgorithmDecoder { impl LogicalAlgorithmDecoder { /// Build from a full-circuit decoder and algorithm descriptor. /// - /// The `full_decoder` is typically an `ObservableSubgraphDecoder` + /// The `full_decoder` is typically an `LogicalSubgraphDecoder` /// built from the full circuit DEM. #[must_use] pub fn new( @@ -148,7 +175,18 @@ impl LogicalAlgorithmDecoder { /// Apply boundary gate to a Pauli frame. /// Used when consuming the frame at logical operations. - pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) { + /// + /// # Errors + /// Returns [`DecoderError::ObservableBitOutOfRange`] if any of the gate's + /// observable bits is `>= 64`. All bits index the `u64` observable frame, so + /// each must be `< 64`; the Python descriptor binding rejects this at + /// construction, and this runtime check guards the same invariant for direct + /// Rust callers (a shift by `>= 64` is otherwise an overflow panic in debug / + /// unspecified in release). + pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) -> Result<(), DecoderError> { + if let Some(&bit) = gate.obs_bits().iter().find(|&&b| b >= 64) { + return Err(DecoderError::ObservableBitOutOfRange { bit }); + } match gate { BoundaryGate::Hadamard { x_obs_bit, @@ -198,6 +236,7 @@ impl LogicalAlgorithmDecoder { } } } + Ok(()) } } @@ -205,6 +244,10 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { self.decode_shot(syndrome) } + + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + self.full_decoder.decode_obs(syndrome) + } } // ============================================================================ @@ -213,7 +256,7 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// Streaming wrapper for `LogicalAlgorithmDecoder`. /// -/// Buffers syndrome data round-by-round. The full-circuit OSD decodes +/// Buffers syndrome data round-by-round. The full-circuit logical-subgraph decoder decodes /// the entire accumulated syndrome at `flush()` for maximum accuracy. /// /// The segment structure tracks which rounds belong to which segment. @@ -224,6 +267,7 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// /// ``` /// use pecos_decoder_core::{DecoderError, ObservableDecoder}; +/// use pecos_decoder_core::obs_mask::ObsMask; /// use pecos_decoder_core::logical_algorithm::{ /// AlgorithmDescriptor, LogicalAlgorithmDecoder, SegmentDescriptor, StreamingLogicalDecoder, /// }; @@ -231,8 +275,8 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// struct AnyDetectionDecoder; /// /// impl ObservableDecoder for AnyDetectionDecoder { -/// fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { -/// Ok(u64::from(syndrome.iter().any(|&bit| bit != 0))) +/// fn decode_obs(&mut self, syndrome: &[u8]) -> Result { +/// Ok(ObsMask::from_u64(u64::from(syndrome.iter().any(|&bit| bit != 0)))) /// } /// } /// @@ -257,7 +301,7 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// assert_eq!(obs, 1); /// ``` pub struct StreamingLogicalDecoder { - /// The underlying batch decoder (full-circuit OSD). + /// The underlying batch decoder (full-circuit logical-subgraph decoder). inner: LogicalAlgorithmDecoder, /// Accumulated syndrome buffer (full circuit size). syndrome: Vec, @@ -305,7 +349,7 @@ impl StreamingLogicalDecoder { self.rounds_fed += 1; } - /// Decode the accumulated syndrome using the full-circuit OSD. + /// Decode the accumulated syndrome using the full-circuit logical-subgraph decoder. /// /// Returns the observable correction mask. This is the final /// correction to apply to raw measurement outcomes. @@ -315,6 +359,18 @@ impl StreamingLogicalDecoder { Ok(obs) } + /// Wide variant of [`Self::flush`]: returns an [`ObsMask`] supporting more + /// than 64 observables (no truncation). + pub fn flush_obs(&mut self) -> Result { + self.inner.decode_obs(&self.syndrome) + } + + /// Wide variant of [`Self::decode_shot`]: feed + flush as an [`ObsMask`]. + pub fn decode_shot_obs(&mut self, syndrome: &[u8]) -> Result { + self.feed_dense(syndrome); + self.flush_obs() + } + /// Decode a full syndrome at once (convenience for batch mode). pub fn decode_shot(&mut self, syndrome: &[u8]) -> Result { self.feed_dense(syndrome); @@ -346,8 +402,11 @@ impl StreamingLogicalDecoder { } /// Apply boundary gate to a Pauli frame (delegates to inner). - pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) { - LogicalAlgorithmDecoder::apply_boundary_gate(frame, gate); + /// + /// # Errors + /// Propagates [`DecoderError::ObservableBitOutOfRange`] from the inner apply. + pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) -> Result<(), DecoderError> { + LogicalAlgorithmDecoder::apply_boundary_gate(frame, gate) } /// Reset for the next shot. @@ -395,7 +454,7 @@ use crate::decode_budget::{DecodeBudget, DecodeStrategy, DetectorRegion}; /// /// - **Offline** (ion trap / simulation): `FullCircuitStrategy` — buffer /// everything, decode at end. Maximum accuracy. -/// - **Streaming** (neutral atom): `CommittedOsdStrategy` — decode and +/// - **Streaming** (neutral atom): `CommittedLogicalSubgraphStrategy` — decode and /// commit at segment boundaries. Bounded memory. /// - **Real-time** (superconducting): windowed UF with ghost protocol /// (future). @@ -457,7 +516,7 @@ impl LogicalCircuitDecoder { /// Decode a full shot (batch mode). /// - /// For offline/ion trap budgets: equivalent to full-circuit OSD. + /// For offline/ion trap budgets: equivalent to full-circuit logical-subgraph decoder. /// For streaming budgets: decodes and commits each segment. pub fn decode_shot(&mut self, full_syndrome: &[u8]) -> Result { self.reset(); @@ -545,6 +604,13 @@ impl ObservableDecoder for LogicalCircuitDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { self.decode_shot(syndrome) } + + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + self.reset(); + let len = syndrome.len().min(self.total_detectors); + self.syndrome[..len].copy_from_slice(&syndrome[..len]); + self.strategy.decode_obs(&self.syndrome) + } } // ============================================================================ @@ -561,7 +627,7 @@ pub struct FullCircuitStrategy { } impl FullCircuitStrategy { - /// Wrap any `ObservableDecoder` (typically OSD). + /// Wrap any `ObservableDecoder` (typically logical-subgraph decoder). #[must_use] pub fn new(decoder: Box) -> Self { Self { inner: decoder } @@ -573,6 +639,10 @@ impl DecodeStrategy for FullCircuitStrategy { self.inner.decode_to_observables(syndrome) } + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + self.inner.decode_obs(syndrome) + } + fn commit(&mut self, _region: &DetectorRegion) -> Result { // Full circuit doesn't commit incrementally Ok(0) @@ -588,49 +658,70 @@ impl DecodeStrategy for FullCircuitStrategy { } // ============================================================================ -// Strategy: Windowed OSD (neutral atom / medium budget) +// Strategy: Windowed logical-subgraph decoding (neutral atom / medium budget) // ============================================================================ -/// Windowed OSD strategy: per-observable subgraph windowed decoding. +/// Windowed logical-subgraph strategy: per-logical-operator subgraph windowed decoding. /// /// Each observable's subgraph is graphlike (no hyperedges). A windowed /// decoder (sandwich or plain PM) runs inside each subgraph with bounded /// latency. The full matching graph is pre-built; only syndrome routing /// and per-window matching are per-shot work. /// -/// This achieves bounded-latency streaming with OSD-level accuracy. -pub struct WindowedOsdStrategy { +/// This achieves bounded-latency streaming with logical-subgraph decoder-level accuracy. +pub struct WindowedLogicalSubgraphStrategy { /// Per-subgraph decoders (windowed or plain). subgraph_decoders: Vec>, /// Per-subgraph detector maps: `subgraph_detector_maps`[i][local] = global. detector_maps: Vec>, + /// Global observable (logical) index each subgraph decodes. Required because + /// callers may pass only the non-empty subgraphs (empty-region observables + /// dropped), so the subgraph's list position is NOT its observable index. + observable_indices: Vec, /// Per-subgraph sub-syndrome buffers (reusable). sub_syndromes: Vec>, - /// Number of observables. - _num_observables: usize, } -impl WindowedOsdStrategy { - /// Build from pre-extracted subgraph DEMs and detector maps. +impl WindowedLogicalSubgraphStrategy { + /// Build from pre-extracted subgraph DEMs, detector maps, and the global + /// observable index each subgraph decodes. /// - /// `subgraph_dems`: per-observable DEM strings (graphlike). - /// `detector_maps`: per-observable local→global detector index maps. + /// `subgraph_dems`: per-subgraph DEM strings (graphlike). + /// `detector_maps`: per-subgraph local→global detector index maps. + /// `observable_indices`: the global observable bit each subgraph flips + /// (each subgraph reports its observable as local bit 0). MUST line up + /// with `subgraph_dems` — when empty-region observables are filtered out, + /// pass the surviving observables' true indices, not `0..n`. /// `factory`: creates the inner decoder for each subgraph DEM. + /// + /// # Errors + /// + /// Returns `DecoderError` if the factory fails, if the three input vectors + /// disagree in length, or if any observable index is >= 64 (the u64 + /// observable mask cannot hold it). pub fn new( subgraph_dems: Vec, detector_maps: Vec>, + observable_indices: Vec, mut factory: F, ) -> Result where F: FnMut(&str) -> Result, DecoderError>, { - let num_observables = subgraph_dems.len(); - let mut decoders = Vec::with_capacity(num_observables); - let mut sub_syndromes = Vec::with_capacity(num_observables); - + let num = subgraph_dems.len(); + if detector_maps.len() != num || observable_indices.len() != num { + return Err(DecoderError::InvalidConfiguration(format!( + "WindowedLogicalSubgraphStrategy: mismatched inputs (dems={num}, \ + maps={}, obs={})", + detector_maps.len(), + observable_indices.len(), + ))); + } + let mut decoders = Vec::with_capacity(num); + let mut sub_syndromes = Vec::with_capacity(num); for (i, dem_str) in subgraph_dems.iter().enumerate() { let dec = factory(dem_str)?; - let n = detector_maps.get(i).map_or(0, std::vec::Vec::len); + let n = detector_maps[i].len(); sub_syndromes.push(vec![0u8; n]); decoders.push(dec); } @@ -638,15 +729,24 @@ impl WindowedOsdStrategy { Ok(Self { subgraph_decoders: decoders, detector_maps, + observable_indices, sub_syndromes, - _num_observables: num_observables, }) } } -impl DecodeStrategy for WindowedOsdStrategy { +impl DecodeStrategy for WindowedLogicalSubgraphStrategy { + /// Narrowing wrapper over [`Self::decode_obs`]; errors above 64 observables. fn decode(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = 0u64; + self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + ) + }) + } + + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = ObsMask::new(); for (i, (dec, dmap)) in self .subgraph_decoders @@ -669,10 +769,12 @@ impl DecodeStrategy for WindowedOsdStrategy { }; } - // Decode this subgraph + // Decode this subgraph: it reports its observable as local bit 0; + // map that to the subgraph's *global* observable bit (not its list + // position `i`, which differs once empty observables are filtered). let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - obs_mask |= 1 << i; + obs_mask.set(self.observable_indices[i]); } } @@ -680,7 +782,13 @@ impl DecodeStrategy for WindowedOsdStrategy { } fn commit(&mut self, _region: &DetectorRegion) -> Result { - // Commitment is handled internally by the windowed inner decoders + // NOTE (abstraction caveat): this strategy is currently a *batch* decoder + // exposed through the streaming `DecodeStrategy` trait. It decodes the + // whole syndrome in one `decode()` call; per-observable subgraph windowing + // (when enabled) is handled inside each inner decoder, not via incremental + // region commits. So `commit()` is intentionally a no-op and + // `committed_obs()` returns 0. Real streaming commit semantics are a + // follow-up (see the windowed logical-subgraph proper-solution design). Ok(0) } @@ -701,8 +809,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } @@ -720,6 +828,57 @@ mod tests { assert_eq!(dec.decode_shot(&[0, 1, 0, 1]).unwrap(), 0b01); } + #[test] + fn windowed_strategy_maps_to_global_observable_index() { + // Two surviving subgraphs whose true (global) observable indices are + // NON-contiguous -- as happens when earlier observables had empty + // regions and were filtered out. Each reports its observable as local + // bit 0; the strategy must flip the GLOBAL bit, not the list position. + // (The pre-fix `1 << i` would have produced bits {0,1} = 0b0011.) + let mut strategy = WindowedLogicalSubgraphStrategy::new( + vec![ + "error(0.1) D0 L0".to_string(), + "error(0.1) D0 L0".to_string(), + ], + vec![vec![0usize], vec![1usize]], + vec![1usize, 3usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ) + .unwrap(); + let obs = strategy.decode(&[1, 1]).unwrap(); + assert_eq!(obs, (1u64 << 1) | (1u64 << 3)); + } + + #[test] + fn windowed_strategy_supports_observable_index_over_63() { + use crate::decode_budget::DecodeStrategy; + // Observable index 64 was previously rejected; it now constructs and the + // wide `decode_obs` represents bit 64 with no truncation. + let mut s = WindowedLogicalSubgraphStrategy::new( + vec!["error(0.1) D0 L0".to_string()], + vec![vec![0usize]], + vec![64usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ) + .unwrap(); + let wide = s.decode_obs(&[1]).unwrap(); + assert!(wide.get(64)); + assert_eq!(wide.to_u64(), None); + // The narrowing u64 path errors rather than truncating. + assert!(s.decode(&[1]).is_err()); + } + + #[test] + fn windowed_strategy_rejects_mismatched_input_lengths() { + let r = WindowedLogicalSubgraphStrategy::new( + vec!["error(0.1) D0 L0".to_string()], + vec![vec![0usize], vec![1usize]], // 2 maps for 1 dem + vec![0usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ); + assert!(r.is_err()); + } + #[test] fn test_hadamard_frame() { let mut frame = 0b01u64; // X correction on bit 0 @@ -729,10 +888,73 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b10); // X became Z } + #[test] + fn test_apply_boundary_gate_rejects_obs_bit_ge_64() { + // A boundary bit >= 64 cannot index the u64 frame; apply must fail loud + // (not panic in debug / shift-overflow in release) for direct Rust callers. + let mut frame = 0u64; + let result = LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::Hadamard { + x_obs_bit: 64, + z_obs_bit: 1, + }, + ); + assert!(matches!( + result, + Err(DecoderError::ObservableBitOutOfRange { bit: 64 }) + )); + assert_eq!( + frame, 0, + "frame must be untouched when the gate is rejected" + ); + } + + #[test] + fn test_boundary_gate_obs_bits_cover_all_fields() { + // The apply-time `< 64` assert relies on obs_bits() listing EVERY bit a + // gate references -- a missed field would let an out-of-range shift slip. + assert_eq!( + BoundaryGate::Hadamard { + x_obs_bit: 2, + z_obs_bit: 5 + } + .obs_bits(), + vec![2, 5] + ); + assert_eq!( + BoundaryGate::Cnot { + ctrl_x_bit: 1, + ctrl_z_bit: 2, + tgt_x_bit: 3, + tgt_z_bit: 4 + } + .obs_bits(), + vec![1, 2, 3, 4] + ); + assert_eq!( + BoundaryGate::SGate { + x_obs_bit: 7, + z_obs_bit: 9 + } + .obs_bits(), + vec![7, 9] + ); + assert_eq!( + BoundaryGate::TGateInjection { + z_obs_bit: 6, + ancilla_z_bit: 8 + } + .obs_bits(), + vec![6, 8] + ); + } + #[test] fn test_cnot_frame() { let mut frame = 0b0001u64; // X on control (bit 0) @@ -744,7 +966,8 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b0101); // X propagated to target } @@ -788,7 +1011,8 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b1010); // Z propagated back to control Z (bit 1) } @@ -804,7 +1028,8 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); // X ctrl -> X tgt (bit 2), Z tgt -> Z ctrl (bit 1) assert_eq!(frame, 0b1111); } @@ -819,7 +1044,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b11); // X stays, Z also set } @@ -833,7 +1059,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b10); // Z stays, no X induced } @@ -846,7 +1073,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0); // No correction, no change } @@ -860,7 +1088,8 @@ mod tests { z_obs_bit: 1, // data Z ancilla_z_bit: 3, // ancilla Z }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b1010); // data Z (bit 1) flipped } @@ -874,7 +1103,8 @@ mod tests { z_obs_bit: 1, ancilla_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b1000); // data Z cancelled, ancilla unchanged } @@ -888,7 +1118,8 @@ mod tests { z_obs_bit: 1, ancilla_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b0010); // unchanged } @@ -902,7 +1133,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b11); // Swap of (1,1) is still (1,1) } @@ -915,7 +1147,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b01); // Z became X } diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs new file mode 100644 index 000000000..3a68a335d --- /dev/null +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -0,0 +1,1128 @@ +// 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-operator subgraph decoder for transversal gates. +//! +//! This decodes a *logical operator's subgraph of the DEM* — not the full DEM. +//! In the Heisenberg picture a detector error model records how errors meet +//! evolving operators: stabilizers evolve into **detectors**, logical operators +//! evolve into **observables**. This decoder groups detectors by their +//! stabilizer (X/Z, per qubit) and restricts the DEM to one logical operator at +//! a time — the errors that can flip it. Each logical operator is effectively +//! its own channel through the circuit (e.g. a transversal CX propagates +//! `XI → XX`, `IZ → ZZ`), so the per-operator problems decouple. +//! +//! The payoff (proved independently by Serra-Peralta et al. arXiv:2505.13599 +//! and Cain et al. arXiv:2505.13587): restricted to one logical operator, the +//! subgraph is always graphlike — only 1-2 detector edges, matchable — even +//! when the full DEM contains weight-3+ hyperedges. So any MWPM-style decoder +//! handles each piece, and the results combine. +//! +//! Naming: abbreviate this "LS decoder" if needed — never "LSD", which collides +//! with BP-LSD (Localised Statistics Decoding) elsewhere in the workspace. +//! +//! # Algorithm +//! +//! 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-logical-operator 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 + +pub mod committed; +pub mod window_plan; + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::ObservableDecoder; +use crate::dem::{DemMatchingGraph, MatchingEdge, SparseDem}; +use crate::errors::DecoderError; +use crate::obs_mask::ObsMask; + +// ============================================================================ +// Stabilizer coordinate mapping +// ============================================================================ + +/// 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 LogicalSubgraph { + /// 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-logical-operator 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 an error if the DEM is malformed, if a detector used in an error +/// mechanism has coordinates that match no stabilizer position in +/// `stab_coords`, or if the DEM declares more than 64 observables (the u64 +/// observable mask cannot hold more). +/// +/// 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_logical( + dem_str: &str, + stab_coords: &StabCoords, +) -> Result, DecoderError> { + partition_dem_by_logical_windowed(dem_str, stab_coords, None) +} + +pub fn partition_dem_by_logical_windowed( + dem_str: &str, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, +) -> Result, DecoderError> { + // Region source (coordinate classification) -> shared subgraph extractor. + let sdem = SparseDem::from_dem_str(dem_str)?; + let membership = coordinate_membership_from_dem(&sdem, stab_coords, max_time_radius)?; + subgraphs_from_membership(&sdem, &membership) +} + +/// Per-observable detector membership: entry `k` is the sorted full-DEM detector +/// ids in logical observable `k`'s observing region. +/// +/// This is the seam between a *region source* and the *subgraph extractor* +/// ([`subgraphs_from_membership`]). The coordinate region source lives here +/// ([`coordinate_membership_from_dem`]); a back-propagation source can live in a +/// higher crate (e.g. `pecos-qec`) — it only needs to produce this same +/// membership and hand it down, which preserves the `pecos-qec -> +/// pecos-decoder-core` dependency direction. +pub type ObservingRegions = Vec>; + +/// Region source: derive each observable's detector membership from the DEM's +/// spatial stabilizer coordinates and boundary edges. +/// +/// This is the algorithm `lomatching` ships for vertex selection +/// (`get_detector_indices_for_subgraphs`): find the 1-detector mechanisms that +/// flip each observable, bucket their detectors into `(qubit, stab_type)` groups +/// by coordinate, and include every detector of those groups at the boundary +/// times. `max_time_radius` widens the per-time inclusion (default `None` = +/// exact boundary times, matching lomatching). +/// +/// # Errors +/// +/// Returns an error if a detector used in an error mechanism has coordinates +/// that match no stabilizer position in `stab_coords`. +pub fn coordinate_membership_from_dem( + sdem: &SparseDem, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, +) -> Result { + let coord_map = &sdem.detector_coords; + + // Detectors that appear in an error mechanism are the only ones that affect + // decoding. Any such detector that fails to classify would be silently + // dropped from every observing region and corrupt the decode, so treat that + // as a configuration error rather than a silent fallback. Detectors that are + // declared but never used (numbering gaps, unused ancillas) are ignored. + let mut used = vec![false; sdem.num_detectors]; + for (_, dets, _) in &sdem.mechanisms { + for &d in dets { + if (d as usize) < sdem.num_detectors { + used[d as usize] = true; + } + } + } + + // Classify each detector into a (qubit, stab_type) group. + let mut det_group: Vec> = vec![None; sdem.num_detectors]; + let mut group_detectors: BTreeMap> = BTreeMap::new(); + let mut unclassified: Vec = Vec::new(); + + for (d, group_slot) in det_group.iter_mut().enumerate().take(sdem.num_detectors) { + let group = match coord_map.get(&d) { + Some(coords) if coords.len() >= 2 => { + classify_detector(coords[0], coords[1], stab_coords) + } + _ => None, + }; + match group { + Some(g) => { + *group_slot = Some(g); + group_detectors.entry(g).or_default().insert(d); + } + None if used[d] => unclassified.push(d), + None => {} + } + } + + if !unclassified.is_empty() { + let shown: Vec = unclassified + .iter() + .take(5) + .map(|&d| match coord_map.get(&d) { + Some(c) => format!("D{d} at {c:?}"), + None => format!("D{d} (no coordinates)"), + }) + .collect(); + return Err(DecoderError::InvalidConfiguration(format!( + "{} detector(s) used in error mechanisms could not be classified against \ + stab_coords (missing coordinates, or a coordinate scale / completeness \ + mismatch). Examples: {}", + unclassified.len(), + shown.join(", "), + ))); + } + + // For each observable, collect its observing region. + let mut membership: ObservingRegions = Vec::with_capacity(sdem.num_observables); + + for obs_idx in 0..sdem.num_observables { + // Step 1: Find boundary edges — 1-detector mechanisms that flip this + // observable. Collect (group, time) from each boundary detector. + 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); + } + } + } + } + + membership.push(region_detectors.into_iter().collect()); + } + + Ok(membership) +} + +/// Subgraph extractor: turn per-observable detector membership into decodable +/// graphlike subgraphs. The membership may come from any region source — the +/// coordinate one ([`coordinate_membership_from_dem`]) or a back-propagation +/// builder in a higher crate. +/// +/// # Errors +/// +/// Supports arbitrarily many observables: flips are packed into a wide +/// [`ObsMask`] by [`ObservableDecoder::decode_obs`]. (The `u64` +/// [`ObservableDecoder::decode_to_observables`] convenience errors above 64; the +/// windowed paths still cap at 64 — see their constructors.) +pub fn subgraphs_from_membership( + sdem: &SparseDem, + membership: &[Vec], +) -> Result, DecoderError> { + let mut subgraphs = Vec::with_capacity(membership.len()); + + for (obs_idx, detectors) in membership.iter().enumerate() { + if detectors.is_empty() { + subgraphs.push(LogicalSubgraph { + observable_idx: obs_idx, + detector_map: Vec::new(), + 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; + } + + // Build detector mapping (full DEM id -> subgraph-local index). + // Validate caller-provided ids first: an out-of-range id would index + // past `inverse_map` and panic, so return a decoder error instead. + let detector_map: Vec = detectors.clone(); + if let Some(&bad) = detector_map.iter().find(|&&d| d >= sdem.num_detectors) { + return Err(DecoderError::InvalidConfiguration(format!( + "subgraphs_from_membership: observable {obs_idx} membership references \ + detector {bad}, but the DEM has only {} detectors (D0..D{})", + sdem.num_detectors, + sdem.num_detectors.saturating_sub(1), + ))); + } + let mut inverse_map = vec![None; sdem.num_detectors]; + for (sub_idx, &full_idx) in detector_map.iter().enumerate() { + inverse_map[full_idx] = Some(sub_idx); + } + + // Extract edges for this subgraph by projecting mechanisms onto the + // membership detectors. + let mut edges = Vec::new(); + let mut skipped = 0; + + for (m, (p, dets, obs)) in sdem.mechanisms.iter().enumerate() { + if *p <= 0.0 { + continue; + } + + 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(LogicalSubgraph { + 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-logical-operator subgraph decoder. +/// +/// Wraps a factory function that creates per-subgraph inner decoders. +/// Any `ObservableDecoder` works as the inner decoder (UF, Fusion Blossom, +/// perturbed ensemble, etc.). +pub struct LogicalSubgraphDecoder { + subgraphs: Vec, + decoders: Vec>, + num_observables: usize, + sub_syndromes: Vec>, +} + +impl LogicalSubgraphDecoder { + /// 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_logical_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, + }) + } + + /// Build from a precomputed per-observable detector membership instead of + /// from `stab_coords`. + /// + /// The membership may come from ANY region source — the coordinate path + /// ([`coordinate_membership_from_dem`]) or a back-propagation / detecting- + /// region source. This is the entry point for comparing alternative + /// observing-region constructions (e.g. the paper's back-propagation region + /// vs the coordinate group-fill) on the same DEM and decoders. + /// + /// # Errors + /// + /// Returns an error if the DEM is malformed, the membership has more than 64 + /// entries, or the factory fails. + pub fn from_membership( + dem: &str, + membership: &[Vec], + mut factory: F, + ) -> Result + where + F: FnMut( + &DemMatchingGraph, + ) -> Result, DecoderError>, + { + let sdem = SparseDem::from_dem_str(dem)?; + let subgraphs = subgraphs_from_membership(&sdem, membership)?; + let num_observables = subgraphs.len(); + + let mut decoders = Vec::with_capacity(subgraphs.len()); + let mut sub_syndromes = Vec::with_capacity(subgraphs.len()); + for sg in &subgraphs { + decoders.push(factory(&sg.graph)?); + sub_syndromes.push(vec![0u8; sg.detector_map.len()]); + } + + Ok(Self { + subgraphs, + decoders, + num_observables, + sub_syndromes, + }) + } + + /// Number of observables. + #[must_use] + pub fn num_observables(&self) -> usize { + self.num_observables + } + + /// Access a subgraph. + #[must_use] + pub fn subgraph(&self, obs_idx: usize) -> Option<&LogicalSubgraph> { + self.subgraphs.get(obs_idx) + } + + /// Build a coord-preserving per-observable window plan from these subgraphs + /// and the full-DEM detector coordinates (indexed by global detector id). + /// + /// Subgraph matching graphs drop detector coordinates, so the windowed + /// decoders need the full-DEM coords re-injected to time-window correctly. + /// The plan also reports whether real windowing would happen or it + /// degenerates to a single-window full decode (see + /// [`window_plan::LogicalSubgraphWindowPlan`]). + #[must_use] + pub fn window_plan( + &self, + full_coords: &[Option>], + ) -> window_plan::LogicalSubgraphWindowPlan { + window_plan::LogicalSubgraphWindowPlan::new(&self.subgraphs, full_coords) + } + + /// Per-observable observing regions: entry `k` is the sorted full-DEM + /// detector ids in observable `k`'s subgraph. This is the membership the + /// region source produced — exposed for differential testing against + /// reference implementations (e.g. lomatching). + #[must_use] + pub fn observing_regions(&self) -> ObservingRegions { + self.subgraphs + .iter() + .map(|sg| sg.detector_map.clone()) + .collect() + } + + /// Batch decode multiple syndromes, returning error count. + /// + /// For each subgraph, extracts all sub-syndromes into a flat buffer + /// 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: &[ObsMask], + ) -> Result { + let num_shots = syndromes.len(); + if num_shots == 0 { + return Ok(0); + } + + // Per-shot observable predictions, accumulated across subgraphs. Wide + // (`ObsMask`) so >64 observables are not truncated. + let mut shot_obs: Vec = vec![ObsMask::new(); num_shots]; + + for (sg, dec) in self.subgraphs.iter().zip(self.decoders.iter_mut()) { + let n = sg.detector_map.len(); + 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].set(sg.observable_idx); + } + } + } + + // Count errors. + let errors = shot_obs + .iter() + .zip(expected_masks.iter()) + .filter(|(predicted, expected)| predicted != expected) + .count(); + + Ok(errors) + } +} + +impl ObservableDecoder for LogicalSubgraphDecoder { + /// Narrowing wrapper over [`Self::decode_obs`]. Errors (rather than + /// truncating) if the decoder has more than 64 observables; callers that may + /// exceed 64 should use [`ObservableDecoder::decode_obs`]. + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + ) + }) + } + + /// Decode every per-observable subgraph and pack the flips into a wide + /// [`ObsMask`], mapping each subgraph's local result to its GLOBAL observable + /// index. Supports more than 64 observables with no truncation. + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = ObsMask::new(); + + for (i, (sg, dec)) in self + .subgraphs + .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.set(sg.observable_idx); + } + } + + Ok(obs_mask) + } +} + +/// Parallel per-logical-operator subgraph decoder using rayon. +pub struct ParallelLogicalSubgraphDecoder { + subgraphs: Vec, + decoders: Vec>>, +} + +impl ParallelLogicalSubgraphDecoder { + /// 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_logical(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 = ObsMask::new(); + for (sg, result) in self.subgraphs.iter().zip(results) { + if result? { + obs_mask.set(sg.observable_idx); + } + } + // This convenience returns a u64; error (don't truncate) above 64. + obs_mask.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; the parallel u64 path supports at most 64" + .into(), + ) + }) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + struct NullDecoder; + impl ObservableDecoder for NullDecoder { + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::new()) + } + } + + struct FixedDecoder(u64); + impl ObservableDecoder for FixedDecoder { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { + if syndrome.iter().any(|&v| v != 0) { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) + } else { + Ok(crate::obs_mask::ObsMask::new()) + } + } + } + + 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_logical(dem, &sc).unwrap(); + assert_eq!(sgs.len(), 1); + // Boundary edge D0 L0 → D0 is qubit 0 X-type. + // Observing region = all qubit-0 X-type detectors = {D0}. + // 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_membership_seam_matches_partition() { + // coordinate_membership_from_dem + subgraphs_from_membership must equal + // the all-in-one partition (the seam is behaviour-preserving). + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(0, 1, 0) D1\n", + "detector(3, 0, 0) D2\n", + "detector(2, 1, 0) D3\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", + "error(0.01) D2 L1\n", + "error(0.01) D2 D3\n", + ); + let sc = simple_stab_coords(); + + let sdem = SparseDem::from_dem_str(dem).unwrap(); + let membership = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(membership, vec![vec![0usize], vec![2usize]]); + + let via_seam = subgraphs_from_membership(&sdem, &membership).unwrap(); + let direct = partition_dem_by_logical(dem, &sc).unwrap(); + assert_eq!(via_seam.len(), direct.len()); + for (a, b) in via_seam.iter().zip(direct.iter()) { + assert_eq!(a.detector_map, b.detector_map); + } + } + + #[test] + fn test_subgraphs_from_external_membership() { + // A region source (e.g. a future back-propagation builder) can hand in + // its own membership and the extractor builds the subgraphs from it. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(1, 0, 1) D1\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", + ); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + // Membership the coordinate path would NOT produce on its own (both times). + let membership = vec![vec![0usize, 1usize]]; + let sgs = subgraphs_from_membership(&sdem, &membership).unwrap(); + assert_eq!(sgs.len(), 1); + assert_eq!(sgs[0].detector_map, vec![0, 1]); + + // >64 observable membership is now SUPPORTED (wide ObsMask), not rejected. + let big: Vec> = (0..65).map(|_| Vec::new()).collect(); + assert_eq!(subgraphs_from_membership(&sdem, &big).unwrap().len(), 65); + + // An out-of-range membership detector id must error, not panic + // (the DEM has 2 detectors D0,D1; detector 5 is past `inverse_map`). + assert!(matches!( + subgraphs_from_membership(&sdem, &[vec![5usize]]), + Err(DecoderError::InvalidConfiguration(_)) + )); + } + + #[test] + fn test_membership_exact_time_vs_radius() { + // D0 and D1 are both qubit-0 X at times 0 and 1. The boundary edge is at + // time 0. Default (None) includes only the boundary time; a radius pulls + // in the neighbouring time step. + let dem = concat!( + "detector(1, 0, 0) D0\n", // qubit 0 X, time 0 + "detector(1, 0, 1) D1\n", // qubit 0 X, time 1 + "error(0.01) D0 L0\n", // boundary at time 0 + "error(0.01) D0 D1\n", + ); + let sc = simple_stab_coords(); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + + let exact = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(exact, vec![vec![0usize]], "None = exact boundary time only"); + + let widened = coordinate_membership_from_dem(&sdem, &sc, Some(1)).unwrap(); + assert_eq!( + widened, + vec![vec![0usize, 1usize]], + "radius pulls in time 1" + ); + } + + #[test] + fn test_decomposed_mechanism_partitions() { + // A `^`-decomposed mechanism is XOR-combined into one 2-detector + // mechanism, so it is NOT a boundary edge and does not seed the region; + // the single-detector `D0 L0` does. Partition must still succeed. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(0, 1, 0) D1\n", + "error(0.01) D0 L0\n", // boundary → qubit 0 X + "error(0.02) D0 ^ D1 L0\n", // decomposed; XOR -> {D0, D1} + ); + let sc = simple_stab_coords(); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + // The `^` line parsed as one mechanism with both detectors. + assert_eq!(sdem.mechanisms.len(), 2); + let membership = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(membership, vec![vec![0usize]]); // D1 (qubit-0 Z) excluded + } + + #[test] + fn test_partition_two_qubits() { + let dem = concat!( + "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_logical(dem, &sc).unwrap(); + assert_eq!(sgs.len(), 2); + assert_eq!(sgs[0].detector_map, vec![0]); // qubit 0 X-type only + assert_eq!(sgs[1].detector_map, vec![2]); // qubit 1 X-type only + } + + #[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 = LogicalSubgraphDecoder::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_unclassified_used_detector_errors() { + // D1 is used in a mechanism but sits at coords matching no stabilizer. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(99, 99, 0) D1\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", // D1 used here, but won't classify + ); + let sc = simple_stab_coords(); + let err = partition_dem_by_logical(dem, &sc).unwrap_err(); + assert!( + matches!(err, DecoderError::InvalidConfiguration(_)), + "expected InvalidConfiguration, got {err:?}" + ); + } + + #[test] + fn test_unclassified_unused_detector_ignored() { + // D1 has bad coords but is never used in a mechanism -> ignored, no error. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(99, 99, 0) D1\n", + "error(0.01) D0 L0\n", + ); + let sc = simple_stab_coords(); + assert!(partition_dem_by_logical(dem, &sc).is_ok()); + } + + #[test] + fn test_single_detector_d0() { + // Regression: a DEM whose only detector is D0 must parse with + // num_detectors == 1 (not 0). D0 used to never set has_any_detector + // because `0 > max_detector` is false, yielding an empty det_group + // and an out-of-bounds panic downstream. + let dem = concat!( + "detector(1, 0, 0) D0\n", // qubit 0 X + "error(0.01) D0 L0\n", // boundary edge on D0 + ); + let sc = simple_stab_coords(); + let sgs = partition_dem_by_logical(dem, &sc).unwrap(); + assert_eq!(sgs.len(), 1); + assert_eq!(sgs[0].detector_map, vec![0]); + } + + #[test] + fn test_more_than_64_observables_decode_wide() { + // 65 observables: construction now SUCCEEDS (no >64 reject), and the wide + // `decode_obs` / `decode_count_batched` paths represent observable 64 + // without truncation. The `u64` convenience method errors instead. + use std::fmt::Write; + let mut dem = String::from("detector(1, 0, 0) D0\n"); + for l in 0..65 { + writeln!(dem, "error(0.01) D0 L{l}").unwrap(); + } + let sc = simple_stab_coords(); + + // Every per-observable subgraph flips its observable when D0 fires. + let mut dec = LogicalSubgraphDecoder::from_dem(&dem, &sc, |_| { + Ok(Box::new(FixedDecoder(1)) as Box) + }) + .unwrap(); + + // Wide decode: all 65 observables flip when D0 is set; bit 64 is present. + let wide = dec.decode_obs(&[1]).unwrap(); + assert_eq!(wide.count_ones(), 65); + assert!(wide.get(64), "observable 64 must be representable"); + assert_eq!(wide.to_u64(), None, "65 bits does not fit a u64"); + + // The u64 convenience errors rather than truncating. + assert!(matches!( + dec.decode_to_observables(&[1]), + Err(DecoderError::InvalidConfiguration(_)) + )); + + // Batch decode-count compares wide masks: an all-flip expected mask + // matches the all-flip prediction (zero errors); a narrower expected + // mask (missing observable 64) is counted as an error. + let mut all_flip = ObsMask::new(); + for l in 0..65 { + all_flip.set(l); + } + let mut missing_64 = ObsMask::new(); + for l in 0..64 { + missing_64.set(l); + } + assert_eq!( + dec.decode_count_batched(&[vec![1]], std::slice::from_ref(&all_flip)) + .unwrap(), + 0 + ); + assert_eq!( + dec.decode_count_batched(&[vec![1]], std::slice::from_ref(&missing_64)) + .unwrap(), + 1 + ); + } + + #[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 = ParallelLogicalSubgraphDecoder::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/committed_osd.rs b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs similarity index 83% rename from crates/pecos-decoder-core/src/committed_osd.rs rename to crates/pecos-decoder-core/src/logical_subgraph/committed.rs index 8157cd6a8..27e2f1e8c 100644 --- a/crates/pecos-decoder-core/src/committed_osd.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs @@ -10,9 +10,9 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! OSD with software commitment for streaming decoding. +//! logical-subgraph decoder with software commitment for streaming decoding. //! -//! Wraps an `ObservableSubgraphDecoder` with per-detector commitment +//! Wraps an `LogicalSubgraphDecoder` with per-detector commitment //! tracking. Committed detectors are masked during future decodes, //! implementing the "software commitment" concept from Cain et al. //! (arXiv:2505.13587). @@ -23,7 +23,7 @@ use crate::ObservableDecoder; use crate::decode_budget::{DecodeStrategy, DetectorRegion}; use crate::errors::DecoderError; -use crate::observable_subgraph::ObservableSubgraphDecoder; +use crate::logical_subgraph::LogicalSubgraphDecoder; /// Observable subgraph decoder with software commitment. /// @@ -34,9 +34,9 @@ use crate::observable_subgraph::ObservableSubgraphDecoder; /// /// The total correction is `committed_obs ^ active_obs`: the XOR /// of committed corrections and the latest active decode. -pub struct CommittedOsdDecoder { - /// The underlying OSD (unchanged). - inner: ObservableSubgraphDecoder, +pub struct CommittedLogicalSubgraphDecoder { + /// The underlying logical-subgraph decoder (unchanged). + inner: LogicalSubgraphDecoder, /// Per-detector commitment state. True = committed. committed: Vec, /// Accumulated observable correction from committed regions. @@ -47,10 +47,10 @@ pub struct CommittedOsdDecoder { masked_syndrome: Vec, } -impl CommittedOsdDecoder { - /// Wrap an existing OSD with commitment tracking. +impl CommittedLogicalSubgraphDecoder { + /// Wrap an existing logical-subgraph decoder with commitment tracking. #[must_use] - pub fn new(inner: ObservableSubgraphDecoder, num_detectors: usize) -> Self { + pub fn new(inner: LogicalSubgraphDecoder, num_detectors: usize) -> Self { Self { inner, committed: vec![false; num_detectors], @@ -63,7 +63,7 @@ impl CommittedOsdDecoder { /// Decode only uncommitted detectors. /// /// Committed detectors are masked to 0 before passing to the - /// inner OSD. Returns the correction for the active (uncommitted) + /// inner logical-subgraph decoder. Returns the correction for the active (uncommitted) /// region. pub fn decode_active(&mut self, syndrome: &[u8]) -> Result { // Build masked syndrome: zero out committed detectors @@ -123,15 +123,17 @@ impl CommittedOsdDecoder { } } -impl ObservableDecoder for CommittedOsdDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { +impl ObservableDecoder for CommittedLogicalSubgraphDecoder { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { // Full decode: committed XOR active let active = self.decode_active(syndrome)?; - Ok(self.committed_obs ^ active) + Ok(crate::obs_mask::ObsMask::from_u64( + self.committed_obs ^ active, + )) } } -impl DecodeStrategy for CommittedOsdDecoder { +impl DecodeStrategy for CommittedLogicalSubgraphDecoder { fn decode(&mut self, syndrome: &[u8]) -> Result { self.decode_active(syndrome) } @@ -150,7 +152,7 @@ impl DecodeStrategy for CommittedOsdDecoder { } fn reset(&mut self) { - CommittedOsdDecoder::reset(self); + CommittedLogicalSubgraphDecoder::reset(self); } } diff --git a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs new file mode 100644 index 000000000..f32da5b5d --- /dev/null +++ b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs @@ -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. + +//! Coord-preserving per-observable subgraph/window plan with honest +//! windowing-mode introspection. +//! +//! Both windowed logical-subgraph decoders (the standalone +//! `WindowedLogicalSubgraphDecoder` in `pecos-uf-decoder`, and the streaming +//! `WindowedLogicalSubgraphStrategy` here) need the same inputs: per-observable +//! graphlike sub-DEMs that PRESERVE detector coordinates (so time-based +//! windowing has real times), the local↔global detector maps, and -- crucially +//! -- a way to report whether real time-windowing will actually happen or the +//! decode silently degenerates to a single full window. +//! +//! Subgraph matching graphs drop detector coordinates +//! ([`crate::logical_subgraph::subgraphs_from_membership`] sets +//! `detector_coords: Vec::new()`), so a sub-DEM serialized from the graph alone +//! has no `detector(...)` lines; any windowed inner then sees `total_t = 1` and +//! builds a single window. This plan injects the full-DEM coordinates (mapped to +//! subgraph-local indices) and exposes the resulting window structure, so +//! callers can FAIL LOUD instead of silently full-decoding behind a +//! bounded-latency API. +//! +//! This lives in `pecos-decoder-core` as shared data/modeling so both the +//! downstream UF decoder and the logical-circuit strategy consume one plan +//! (avoiding a `decoder-core -> pecos-uf-decoder` dependency). + +use crate::logical_subgraph::LogicalSubgraph; +use std::fmt::Write as _; + +/// Whether a windowed logical-subgraph decode actually time-windows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectiveWindowing { + /// Every per-observable subgraph fits in a single window: a full + /// (non-windowed) decode -- accurate, but unbounded latency. Selecting this + /// when bounded latency was requested is a silent fallback unless surfaced. + FullFallback, + /// At least one subgraph spans multiple time windows: real sliding-window + /// decoding (bounded latency, subject to the windowed-LOM accuracy limit). + RealWindowed, +} + +impl EffectiveWindowing { + /// Stable string label for APIs / tests. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + EffectiveWindowing::FullFallback => "full_fallback", + EffectiveWindowing::RealWindowed => "real_windowed", + } + } +} + +/// One per-observable subgraph with detector coordinates preserved. +pub struct PlanEntry { + /// Global observable (logical) index this subgraph decodes. + pub observable_idx: usize, + /// Subgraph-local detector index -> full-DEM detector index. + pub detector_map: Vec, + /// Coord-preserving sub-DEM: `detector(...)` lines + `error(...)` lines. + pub sub_dem: String, + /// Per-local-detector time (coordinate element 2; `0.0` if unknown). + pub detector_times: Vec, +} + +/// Coord-preserving per-observable subgraph/window plan. +pub struct LogicalSubgraphWindowPlan { + entries: Vec, +} + +impl LogicalSubgraphWindowPlan { + /// Build from per-observable subgraphs and the full-DEM detector + /// coordinates (indexed by global detector id). Empty-region observables + /// (no detectors) are skipped -- they never flip and contribute nothing. + #[must_use] + pub fn new(subgraphs: &[LogicalSubgraph], full_coords: &[Option>]) -> Self { + let mut entries = Vec::new(); + for sg in subgraphs { + if sg.detector_map.is_empty() { + continue; + } + let mut detector_times = Vec::with_capacity(sg.detector_map.len()); + let mut sub_dem = String::new(); + for (local, &global) in sg.detector_map.iter().enumerate() { + let coords = full_coords.get(global).and_then(|c| c.as_ref()); + let t = coords.and_then(|c| c.get(2).copied()).unwrap_or(0.0); + detector_times.push(t); + if let Some(c) = coords { + let cs: Vec = c.iter().map(|v| format!("{v}")).collect(); + let _ = writeln!(sub_dem, "detector({}) D{local}", cs.join(", ")); + } + } + for edge in &sg.graph.edges { + let _ = write!(sub_dem, "error({})", edge.probability); + let _ = write!(sub_dem, " D{}", edge.node1); + if let Some(n2) = edge.node2 { + let _ = write!(sub_dem, " D{n2}"); + } + for &obs in &edge.observables { + let _ = write!(sub_dem, " L{obs}"); + } + let _ = writeln!(sub_dem); + } + entries.push(PlanEntry { + observable_idx: sg.observable_idx, + detector_map: sg.detector_map.clone(), + sub_dem, + detector_times, + }); + } + Self { entries } + } + + /// Number of non-empty per-observable subgraphs in the plan. + #[must_use] + pub fn num_observables(&self) -> usize { + self.entries.len() + } + + /// The per-observable plan entries. + #[must_use] + pub fn entries(&self) -> &[PlanEntry] { + &self.entries + } + + /// Coord-preserving sub-DEM strings (one per non-empty observable). + #[must_use] + pub fn sub_dems(&self) -> Vec { + self.entries.iter().map(|e| e.sub_dem.clone()).collect() + } + + /// Local->global detector maps (one per non-empty observable). + #[must_use] + pub fn detector_maps(&self) -> Vec> { + self.entries + .iter() + .map(|e| e.detector_map.clone()) + .collect() + } + + /// Estimated number of time windows observable `i` would use at `step` + /// rounds per window. + /// + /// This is an ESTIMATE, not a guaranteed match of the exact window count an + /// `OverlappingWindowedDecoder` builds: it counts core ranges that contain a + /// detector and ignores the buffer overlap, and it requires an explicit + /// `step` (the real decoder auto-derives `step` from the graph when none is + /// given). It is sufficient for the load-bearing use here -- the + /// [`Self::effective_windowing`] FullFallback-vs-RealWindowed *boolean*, + /// which depends only on `total_t` vs `step`, not on buffer details. Exact + /// counts should single-source the decoder's own loop (a Layer C item when + /// the windowing construction is revisited; see the proper-solution design + /// doc). + #[must_use] + pub fn window_count(&self, i: usize, step: usize) -> usize { + self.entries + .get(i) + .map_or(0, |e| window_count_for_times(&e.detector_times, step)) + } + + /// Total windows across all observables at `step`. + #[must_use] + pub fn total_windows(&self, step: usize) -> usize { + (0..self.entries.len()) + .map(|i| self.window_count(i, step)) + .sum() + } + + /// Whether real time-windowing happens at `step`, or it degenerates to a + /// single-window full decode for every observable. + #[must_use] + pub fn effective_windowing(&self, step: usize) -> EffectiveWindowing { + if (0..self.entries.len()).any(|i| self.window_count(i, step) > 1) { + EffectiveWindowing::RealWindowed + } else { + EffectiveWindowing::FullFallback + } + } +} + +/// Count the time windows for a set of detector times at `step` rounds per +/// window, matching the sliding-window loop's core ranges. Only windows that +/// contain at least one detector are counted. With no coordinates (all times +/// `0.0`) this returns 1 -- the silent-fallback signal. +fn window_count_for_times(times: &[f64], step: usize) -> usize { + if times.is_empty() { + return 0; + } + let max_time = times.iter().copied().fold(0.0f64, f64::max); + let total_t = max_time + 1.0; + let step = step.max(1) as f64; + + let mut count = 0usize; + let mut t_start = 0.0f64; + while t_start < total_t { + let is_last = t_start + 2.0 * step > total_t; + let t_core_end = if is_last { + total_t + 1.0 + } else { + t_start + step + }; + if times.iter().any(|&t| t >= t_start && t < t_core_end) { + count += 1; + } + if is_last { + break; + } + t_start += step; + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordless_times_are_single_window() { + // All detectors at time 0 (the subgraph-graph / no-coords case). + assert_eq!(window_count_for_times(&[0.0, 0.0, 0.0], 4), 1); + assert_eq!(window_count_for_times(&[0.0], 1), 1); + } + + #[test] + fn empty_times_are_zero_windows() { + assert_eq!(window_count_for_times(&[], 4), 0); + } + + #[test] + fn multi_round_times_window_by_step() { + // Times 0..=23 (24 rounds), step 4 -> several windows (> 1). + let times: Vec = (0..24).map(f64::from).collect(); + assert!(window_count_for_times(×, 4) > 1); + // A step covering the whole range -> a single window. + assert_eq!(window_count_for_times(×, 1000), 1); + } +} diff --git a/crates/pecos-decoder-core/src/multi_decoder.rs b/crates/pecos-decoder-core/src/multi_decoder.rs index c88e291b6..b5d4c5ea4 100644 --- a/crates/pecos-decoder-core/src/multi_decoder.rs +++ b/crates/pecos-decoder-core/src/multi_decoder.rs @@ -174,8 +174,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/obs_mask.rs b/crates/pecos-decoder-core/src/obs_mask.rs new file mode 100644 index 000000000..0e10f6aea --- /dev/null +++ b/crates/pecos-decoder-core/src/obs_mask.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. + +//! Observable flip mask supporting arbitrarily many logical observables. +//! +//! Decoders report "which logical observables flipped" as a bitmask (bit `i` = +//! observable `i`). Historically this was a `u64`, capping observables at 64. +//! [`ObsMask`] lifts that cap while keeping the common case fast: +//! +//! - **<= 64 observables -> one inline word, zero heap allocation** (a +//! `SmallVec<[u64; 1]>` keeps the single word on the stack). The per-shot +//! decode hot path is unchanged from the old `u64`. +//! - **> 64 observables -> spills to N words on the heap**, no truncation. +//! +//! Word `w` holds observable bits `64*w ..= 64*w + 63` (little-endian). Trailing +//! zero words are permitted; equality compares the represented bit set, not the +//! storage, so `ObsMask::from_u64(0)`, `ObsMask::new()`, and a two-word `[5, 0]` +//! vs one-word `[5]` all compare as expected. + +use smallvec::{SmallVec, smallvec}; +use std::ops::{BitAndAssign, BitXorAssign}; + +const WORD_BITS: usize = u64::BITS as usize; + +/// A logical-observable flip mask of unbounded width. +/// +/// See the module docs. Cheap (`Copy`-like, one inline word) for the common +/// `<= 64` case; spills to the heap only beyond 64 observables. +#[derive(Clone, Debug, Default)] +pub struct ObsMask { + words: SmallVec<[u64; 1]>, +} + +impl ObsMask { + /// An empty (all-zero) mask. No heap allocation. + #[must_use] + pub fn new() -> Self { + Self { + words: SmallVec::new(), + } + } + + /// A mask from a single 64-bit word (observables 0..=63). No heap allocation. + #[must_use] + pub fn from_u64(value: u64) -> Self { + Self { + words: smallvec![value], + } + } + + /// Sets observable bit `bit` to 1, growing the storage if needed. + pub fn set(&mut self, bit: usize) { + let word = bit / WORD_BITS; + if word >= self.words.len() { + self.words.resize(word + 1, 0); + } + self.words[word] |= 1u64 << (bit % WORD_BITS); + } + + /// Returns whether observable bit `bit` is set. + #[must_use] + pub fn get(&self, bit: usize) -> bool { + let word = bit / WORD_BITS; + self.words + .get(word) + .is_some_and(|w| (w >> (bit % WORD_BITS)) & 1 != 0) + } + + /// Returns whether no observable bit is set. + #[must_use] + pub fn is_zero(&self) -> bool { + self.words.iter().all(|&w| w == 0) + } + + /// Number of set observable bits. + #[must_use] + pub fn count_ones(&self) -> u32 { + self.words.iter().map(|w| w.count_ones()).sum() + } + + /// The mask as a single `u64` if it fits in 64 bits, else `None`. + #[must_use] + pub fn to_u64(&self) -> Option { + if self.words.iter().skip(1).all(|&w| w == 0) { + Some(self.words.first().copied().unwrap_or(0)) + } else { + None + } + } + + /// The backing little-endian words (lowest observables first). Trailing zero + /// words may be present. Used to bridge to/from external representations + /// (e.g. a Python arbitrary-precision integer). + #[must_use] + pub fn words(&self) -> &[u64] { + &self.words + } + + /// Builds a mask from little-endian words (lowest observables first). + #[must_use] + pub fn from_words(words: &[u64]) -> Self { + Self { + words: SmallVec::from_slice(words), + } + } + + /// Iterates the indices of the set observable bits, ascending. + pub fn iter_set_bits(&self) -> impl Iterator + '_ { + self.words.iter().enumerate().flat_map(|(w, &word)| { + (0..WORD_BITS) + .filter(move |b| (word >> b) & 1 != 0) + .map(move |b| w * WORD_BITS + b) + }) + } +} + +impl From for ObsMask { + fn from(value: u64) -> Self { + Self::from_u64(value) + } +} + +impl BitXorAssign<&ObsMask> for ObsMask { + fn bitxor_assign(&mut self, rhs: &ObsMask) { + if rhs.words.len() > self.words.len() { + self.words.resize(rhs.words.len(), 0); + } + for (w, &r) in self.words.iter_mut().zip(rhs.words.iter()) { + *w ^= r; + } + } +} + +impl BitAndAssign<&ObsMask> for ObsMask { + fn bitand_assign(&mut self, rhs: &ObsMask) { + // Keep only bits set in both; words beyond `rhs` become zero (rhs has no + // bits there). Value equality ignores the resulting trailing zero words. + for (i, w) in self.words.iter_mut().enumerate() { + *w &= rhs.words.get(i).copied().unwrap_or(0); + } + } +} + +impl PartialEq for ObsMask { + fn eq(&self, other: &Self) -> bool { + // Value equality: compare every word, treating missing words as zero, so + // representations that differ only by trailing zero words are equal. + let n = self.words.len().max(other.words.len()); + (0..n).all(|i| { + self.words.get(i).copied().unwrap_or(0) == other.words.get(i).copied().unwrap_or(0) + }) + } +} + +impl Eq for ObsMask {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_is_zero() { + let m = ObsMask::new(); + assert!(m.is_zero()); + assert_eq!(m.count_ones(), 0); + assert_eq!(m.to_u64(), Some(0)); + } + + #[test] + fn from_u64_roundtrips() { + let m = ObsMask::from_u64(0b1011); + assert!(m.get(0)); + assert!(m.get(1)); + assert!(!m.get(2)); + assert!(m.get(3)); + assert_eq!(m.to_u64(), Some(0b1011)); + assert_eq!(m.count_ones(), 3); + } + + #[test] + fn set_below_64_stays_inline() { + let mut m = ObsMask::new(); + m.set(0); + m.set(63); + assert!(m.get(0)); + assert!(m.get(63)); + assert_eq!(m.words().len(), 1, "<=64 observables must use one word"); + assert_eq!(m.to_u64(), Some((1u64 << 63) | 1)); + } + + #[test] + fn set_at_and_above_64_spills_wide() { + let mut m = ObsMask::new(); + m.set(5); + m.set(64); + m.set(130); + assert!(m.get(5)); + assert!(m.get(64)); + assert!(m.get(130)); + assert!(!m.get(63)); + assert_eq!(m.words().len(), 3, "bit 130 needs 3 words"); + assert_eq!(m.to_u64(), None, "does not fit in 64 bits"); + assert_eq!(m.count_ones(), 3); + assert_eq!(m.iter_set_bits().collect::>(), vec![5, 64, 130]); + } + + #[test] + fn xor_same_and_mixed_width() { + let mut a = ObsMask::from_u64(0b1100); + a ^= &ObsMask::from_u64(0b1010); + assert_eq!(a.to_u64(), Some(0b0110)); + + // Wide ^ narrow grows the narrow side. + let mut wide = ObsMask::new(); + wide.set(70); + let mut narrow = ObsMask::from_u64(0b1); + narrow ^= &wide; + assert!(narrow.get(0)); + assert!(narrow.get(70)); + assert_eq!(narrow.count_ones(), 2); + + // x ^ x == 0 at any width. + let mut w = ObsMask::new(); + w.set(200); + let snapshot = w.clone(); + w ^= &snapshot; + assert!(w.is_zero()); + } + + #[test] + fn and_restricts_to_common_bits() { + let mut a = ObsMask::new(); + a.set(1); + a.set(64); + a.set(130); + let mut mask = ObsMask::new(); + mask.set(1); + mask.set(64); // mask omits 130 + a &= &mask; + assert!(a.get(1)); + assert!(a.get(64)); + assert!(!a.get(130), "bit outside the mask is cleared"); + assert_eq!(a.count_ones(), 2); + } + + #[test] + fn equality_ignores_trailing_zero_words() { + assert_eq!(ObsMask::new(), ObsMask::from_u64(0)); + assert_eq!(ObsMask::from_u64(5), ObsMask::from_words(&[5, 0, 0])); + assert_ne!(ObsMask::from_u64(5), ObsMask::from_words(&[5, 1])); + let mut wide_zero = ObsMask::new(); + wide_zero.set(100); + wide_zero.set(100); // toggle on, still set + assert_ne!(wide_zero, ObsMask::new()); + } + + #[test] + fn from_words_and_words_roundtrip() { + let m = ObsMask::from_words(&[0xff, 0x1]); + assert_eq!(m.words(), &[0xff, 0x1]); + assert!(m.get(0)); + assert!(m.get(64)); + assert_eq!(m.count_ones(), 9); + } +} diff --git a/crates/pecos-decoder-core/src/observable_subgraph.rs b/crates/pecos-decoder-core/src/observable_subgraph.rs deleted file mode 100644 index 31ce1480a..000000000 --- a/crates/pecos-decoder-core/src/observable_subgraph.rs +++ /dev/null @@ -1,949 +0,0 @@ -// Copyright 2026 The PECOS Developers -// -// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software distributed under the License -// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -// or implied. See the License for the specific language governing permissions and limitations under -// the License. - -//! 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 index c5182c999..695957f6e 100644 --- a/crates/pecos-decoder-core/src/pauli_frame.rs +++ b/crates/pecos-decoder-core/src/pauli_frame.rs @@ -21,13 +21,14 @@ //! //! ``` //! use pecos_decoder_core::{DecoderError, ObservableDecoder}; +//! use pecos_decoder_core::obs_mask::ObsMask; //! use pecos_decoder_core::pauli_frame::PauliFrameAccumulator; //! //! struct FixedDecoder(u64); //! //! impl ObservableDecoder for FixedDecoder { -//! fn decode_to_observables(&mut self, _syndrome: &[u8]) -> Result { -//! Ok(self.0) +//! fn decode_obs(&mut self, _syndrome: &[u8]) -> Result { +//! Ok(ObsMask::from_u64(self.0)) //! } //! } //! @@ -185,8 +186,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/perturbed.rs b/crates/pecos-decoder-core/src/perturbed.rs index a2fb1e81e..7386a32ec 100644 --- a/crates/pecos-decoder-core/src/perturbed.rs +++ b/crates/pecos-decoder-core/src/perturbed.rs @@ -198,11 +198,11 @@ mod tests { // Trivial decoder that always returns 0. struct Zero; impl crate::ObservableDecoder for Zero { - fn decode_to_observables( + fn decode_obs( &mut self, _: &[u8], - ) -> Result { - Ok(0) + ) -> Result { + Ok(crate::obs_mask::ObsMask::new()) } } Ok(Box::new(Zero)) diff --git a/crates/pecos-decoder-core/src/telemetry.rs b/crates/pecos-decoder-core/src/telemetry.rs index 395fc6940..ea3d2d327 100644 --- a/crates/pecos-decoder-core/src/telemetry.rs +++ b/crates/pecos-decoder-core/src/telemetry.rs @@ -145,12 +145,13 @@ impl TelemetryDecoder { } impl ObservableDecoder for TelemetryDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { let syndrome_weight = syndrome.iter().filter(|&&v| v != 0).count() as u64; let start = Instant::now(); - let obs = self.inner.decode_to_observables(syndrome)?; + let obs = self.inner.decode_obs(syndrome)?; let elapsed_ns = start.elapsed().as_nanos() as u64; - self.stats.record(elapsed_ns, syndrome_weight, obs != 0); + self.stats + .record(elapsed_ns, syndrome_weight, !obs.is_zero()); Ok(obs) } } @@ -161,8 +162,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/two_pass_decoder.rs b/crates/pecos-decoder-core/src/two_pass_decoder.rs index 8c7298855..d081719af 100644 --- a/crates/pecos-decoder-core/src/two_pass_decoder.rs +++ b/crates/pecos-decoder-core/src/two_pass_decoder.rs @@ -59,11 +59,11 @@ impl TwoPassDecoder { } impl crate::ObservableDecoder for TwoPassDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { if !self.correlation_table.has_correlations() { // No correlations: single-pass decode (no overhead) let (mask, _) = self.inner.decode_with_matching(syndrome)?; - return Ok(mask); + return Ok(crate::obs_mask::ObsMask::from_u64(mask)); } // First pass: decode to get matched edges @@ -87,7 +87,7 @@ impl crate::ObservableDecoder for TwoPassDecoder { let (mask, _) = self .inner .decode_with_weights(syndrome, &self.adjusted_weights)?; - Ok(mask) + Ok(crate::obs_mask::ObsMask::from_u64(mask)) } } diff --git a/crates/pecos-decoder-core/src/windowed_osd.rs b/crates/pecos-decoder-core/src/windowed_osd.rs deleted file mode 100644 index 00e575ffe..000000000 --- a/crates/pecos-decoder-core/src/windowed_osd.rs +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright 2026 The PECOS Developers -// -// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software distributed under the License -// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -// or implied. See the License for the specific language governing permissions and limitations under -// the License. - -//! Windowed observable subgraph decoder. -//! -//! Splits a DEM into time windows, runs per-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 index 38073875c..7745910f4 100644 --- a/crates/pecos-decoder-core/tests/ensemble_integration.rs +++ b/crates/pecos-decoder-core/tests/ensemble_integration.rs @@ -23,12 +23,17 @@ struct ConfigurableDecoder { } impl ObservableDecoder for ConfigurableDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let has_defects = syndrome.iter().any(|&v| v != 0); if has_defects { - Ok(self.defect_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + self.defect_mask, + )) } else { - Ok(0) + Ok(pecos_decoder_core::obs_mask::ObsMask::new()) } } } @@ -37,7 +42,10 @@ impl ObservableDecoder for ConfigurableDecoder { struct FailingDecoder; impl ObservableDecoder for FailingDecoder { - fn decode_to_observables(&mut self, _syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + _syndrome: &[u8], + ) -> Result { Err(DecoderError::DecodingFailed("always fails".into())) } } diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 5c9906a45..31b60cf46 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -21,10 +21,9 @@ pub use pecos_decoder_core::{ }; // Re-export observable subgraph decoder (for transversal gates) -pub use pecos_decoder_core::observable_subgraph::{ - DetectorGroup, ObservableSubgraph, ObservableSubgraphDecoder, - ParallelObservableSubgraphDecoder, QubitStabCoords, StabCoords, StabType, - partition_dem_by_observable, +pub use pecos_decoder_core::logical_subgraph::{ + DetectorGroup, LogicalSubgraph, LogicalSubgraphDecoder, ParallelLogicalSubgraphDecoder, + QubitStabCoords, StabCoords, StabType, partition_dem_by_logical, }; // Re-export LDPC decoders when feature is enabled @@ -103,7 +102,7 @@ pub use pecos_uf_decoder::{ AStarConfig, AStarDecoder, BeamSearchConfig, BeamSearchWindowedDecoder, BpSchedule as UfBpSchedule, BpUfConfig, BpUfDecoder, CssUfDecoder, OverlappingWindowedDecoder, QubitEdgeMapping, SandwichWindowedDecoder, StreamingWindowedDecoder, UfDecoder, - UfDecoderConfig, WindowedConfig, WindowedDecoder, + UfDecoderConfig, WindowedConfig, WindowedDecoder, WindowedLogicalSubgraphDecoder, }; // Re-export Relay BP decoder when feature is enabled diff --git a/crates/pecos-engines/Cargo.toml b/crates/pecos-engines/Cargo.toml index 2793a7d11..0f217c7b3 100644 --- a/crates/pecos-engines/Cargo.toml +++ b/crates/pecos-engines/Cargo.toml @@ -27,6 +27,7 @@ num-bigint.workspace = true bitvec.workspace = true pecos-core.workspace = true +pecos-results.workspace = true pecos-simulators.workspace = true pecos-random.workspace = true diff --git a/crates/pecos-engines/src/classical.rs b/crates/pecos-engines/src/classical.rs index 8204ca878..e906c0314 100644 --- a/crates/pecos-engines/src/classical.rs +++ b/crates/pecos-engines/src/classical.rs @@ -10,6 +10,26 @@ use std::any::Any; pub trait ClassicalEngine: Engine + DynClone + Send + Sync { fn num_qubits(&self) -> usize; + /// Provide a qubit-count hint from higher-level simulation configuration. + /// + /// Most classical engines can ignore this. Dynamic runtimes may need it + /// before program execution discovers allocations. + fn set_num_qubits_hint(&mut self, _num_qubits: usize) { + // Default implementation does nothing. + } + + /// Whether this engine's qubit count is only known after execution because it + /// allocates qubits dynamically. + /// + /// For such engines a [`Self::num_qubits`] of 0 before execution means "not + /// yet known", not "genuinely zero qubits". Static engines that parse their + /// whole program up front (e.g. QASM) know their exact count and return + /// `false` (the default); dynamic runtimes (e.g. the QIS/Selene runtime, + /// which discovers allocations during execution) return `true`. + fn has_dynamic_qubit_count(&self) -> bool { + false + } + /// Generate a `ByteMessage` containing the next batch of quantum commands to execute. /// An empty message indicates no more commands are available. /// @@ -98,6 +118,14 @@ impl ClassicalEngine for Box { (**self).num_qubits() } + fn set_num_qubits_hint(&mut self, num_qubits: usize) { + (**self).set_num_qubits_hint(num_qubits); + } + + fn has_dynamic_qubit_count(&self) -> bool { + (**self).has_dynamic_qubit_count() + } + fn generate_commands(&mut self) -> Result { (**self).generate_commands() } diff --git a/crates/pecos-engines/src/lib.rs b/crates/pecos-engines/src/lib.rs index e24ac46ce..4214ff405 100644 --- a/crates/pecos-engines/src/lib.rs +++ b/crates/pecos-engines/src/lib.rs @@ -10,6 +10,7 @@ pub mod prelude; pub mod quantum; pub mod quantum_engine_builder; pub mod quantum_system; +pub mod sampling; pub mod shot_results; pub mod sim_builder; @@ -40,6 +41,7 @@ pub use quantum_engine_builder::{ state_vector, }; pub use quantum_system::QuantumSystem; +pub use sampling::MonteCarloBuilder; pub use shot_results::data_vec::DataVecType; pub use shot_results::{ BitVecDisplayFormat, Data, DataVec, Shot, ShotMap, ShotMapDisplay, ShotMapDisplayExt, diff --git a/crates/pecos-engines/src/noise.rs b/crates/pecos-engines/src/noise.rs index 5b30e2261..cdb08eada 100644 --- a/crates/pecos-engines/src/noise.rs +++ b/crates/pecos-engines/src/noise.rs @@ -28,7 +28,7 @@ pub use self::biased_depolarizing::{ BiasedDepolarizingNoiseModel, BiasedDepolarizingNoiseModelBuilder, }; pub use self::depolarizing::{DepolarizingNoiseModel, DepolarizingNoiseModelBuilder}; -pub use self::general::{GeneralNoiseModel, GeneralNoiseModelBuilder}; +pub use self::general::{GeneralNoiseModel, GeneralNoiseModelBuilder, PauliWithAngleScaling}; pub use self::noise_rng::NoiseRng; pub use self::pass_through::{PassThroughNoiseModel, PassThroughNoiseModelBuilder}; pub use self::utils::{NoiseUtils, ProbabilityValidator}; diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index d64b4dde2..e00dc3066 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -77,7 +77,7 @@ mod builder; mod default; -pub use self::builder::GeneralNoiseModelBuilder; +pub use self::builder::{GeneralNoiseModelBuilder, PauliWithAngleScaling}; use crate::Gate; use crate::byte_message::{ByteMessage, ByteMessageBuilder, GateType}; @@ -505,6 +505,40 @@ impl GeneralNoiseModel { ) } + /// Get the angle-dependent two-qubit error coefficients `(a, b, c, d)`. + /// + /// These parameterize the error rate as a function of the rotation angle + /// (see [`Self::p2_angle_error_rate`]). Exposed so other simulation stacks + /// can read the model's defaults without re-declaring them. + #[must_use] + pub fn p2_angle_params(&self) -> (f64, f64, f64, f64) { + ( + self.p2_angle_a, + self.p2_angle_b, + self.p2_angle_c, + self.p2_angle_d, + ) + } + + /// Get the angle-dependent two-qubit error power exponent. + #[must_use] + pub fn p2_angle_power(&self) -> f64 { + self.p2_angle_power + } + + /// Get the single-qubit spontaneous-emission ratio (fraction of 1q gate + /// errors that are emission faults, which replace the gate). + #[must_use] + pub fn p1_emission_ratio(&self) -> f64 { + self.p1_emission_ratio + } + + /// Get the two-qubit spontaneous-emission ratio. + #[must_use] + pub fn p2_emission_ratio(&self) -> f64 { + self.p2_emission_ratio + } + /// Apply noise at the start of `QuantumSystem` processing (typically a collection of gates) /// /// # Panics @@ -608,9 +642,17 @@ impl GeneralNoiseModel { self.apply_sq_faults(&gate, &mut builder); } _ if gate.is_two_qubit() => { - // For angle-dependent error rates (rotation gates like RZZ) + // For angle-dependent error rates (rotation gates like RZZ). + // Use the SIGNED principal value (-pi, pi]: `p2_angle_error_rate` + // is sign-aware (separate coefficients for negative vs positive + // angles), and the gate unitaries themselves read the angle via + // `to_radians_signed`. The unsigned `to_radians` ([0, 2pi)) is + // always non-negative, so it would make the negative-angle + // coefficients dead via the gate path and assign RZZ(-pi/2) the + // noise of a 3pi/2 rotation (the two are equal up to global + // phase, so they must share a noise rate). let p2 = if gate.angle_arity() >= 1 && !gate.angles.is_empty() { - let angle = gate.angles[0].to_radians(); + let angle = gate.angles[0].to_radians_signed(); self.p2_angle_error_rate(angle) } else { self.p2 diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index 4ea98e3f6..b6662620a 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -5,6 +5,27 @@ use crate::noise::{ }; use std::collections::{BTreeMap, BTreeSet}; +/// The plain-Pauli probabilities plus optional angle-dependent two-qubit +/// scaling and the spontaneous-emission ratios, as returned by +/// [`GeneralNoiseModelBuilder::pauli_with_angle_scaling`]. +/// +/// Layout: +/// `(p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission_ratio, p2_emission_ratio)` +/// where `angle` is `Some((a, b, c, d, power))` when angle scaling is +/// configured. The emission ratios use the model default (0.5) when unset, and +/// the emission DISTRIBUTION is required to be the default (uniform Pauli) -- +/// custom emission models keep the config out of this subset. +pub type PauliWithAngleScaling = ( + f64, + f64, + f64, + f64, + f64, + Option<(f64, f64, f64, f64, f64)>, + f64, + f64, +); + /// Builder for creating general noise models #[derive(Debug, Clone)] pub struct GeneralNoiseModelBuilder { @@ -735,6 +756,194 @@ impl GeneralNoiseModelBuilder { } // ========================================================================================== // + /// The simple Pauli-probability subset of this configuration, if the + /// physics reduces to it. + /// + /// Returns `(p_prep, p_meas_0, p_meas_1, p1, p2)`. `p1`/`p2` are in the + /// standard depolarizing convention the builder stores internally (the + /// `with_average_*` setters convert on the way in). Unset probabilities + /// take their `GeneralNoiseModel::default()` values — this model's + /// philosophy is realistic defaults, NOT unset-means-off. + /// + /// Returns `Some` only when the noise shape is plain Pauli noise: + /// + /// - Knobs whose model defaults are non-neutral must be EXPLICITLY + /// zeroed: emission ratios (default 0.5 — half the errors replace the + /// gate instead of following it), prep leak ratio (default 0.5), and + /// the linear idle rate (default 0.001). + /// - Knobs with neutral defaults (crosstalk, quadratic idle, scales, + /// noiseless gates) may be unset or set to their neutral value. + /// - Custom Pauli/emission/crosstalk models and angle-dependent + /// two-qubit noise must be unset. + /// + /// A configured seed is ignored (it selects a random stream, not + /// physics). This exists so other simulation stacks can translate the + /// common configuration without re-deriving probability conventions. + #[must_use] + pub fn simple_probabilities(&self) -> Option<(f64, f64, f64, f64, f64)> { + let emission_off = + self.p1_emission_ratio == Some(0.0) && self.p2_emission_ratio == Some(0.0); + if self.is_plain_pauli_except_angle_and_emission() + && self.resolved_angle_scaling().is_none() + && emission_off + { + Some(self.resolved_base_probabilities()) + } else { + None + } + } + + /// Like [`Self::simple_probabilities`], but ALSO permits angle-dependent + /// two-qubit scaling and returns it alongside the base probabilities. + /// + /// Returns `(p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission, + /// p2_emission)` where `angle` is `Some((a, b, c, d, power))` when any + /// `p2_angle_*` parameter is configured (the unset components take their + /// model defaults), and `None` otherwise. This lets other simulation stacks + /// translate the common "plain Pauli, plus optional angle-dependent + /// two-qubit gate noise" configuration — the angle-dependent error rate is + /// `p2 * (coeff * |theta/pi|^power + offset)` with separate + /// `(a, b)` for negative and `(c, d)` for positive angles + /// (see [`GeneralNoiseModel::p2_angle_error_rate`]). + /// + /// The two `*_emission` ratios are the resolved spontaneous-emission + /// fractions (unset components take the model default). Emission is + /// gate-removing in both engines and neo, so a downstream stack can + /// reproduce it exactly by carrying these ratios with the default uniform + /// emission distribution. + /// + /// All the OTHER non-angle feature requirements of + /// [`Self::simple_probabilities`] still apply (leakage, idle, crosstalk, + /// scales, custom samplers including custom emission distributions, and + /// noiseless gates must be off). + #[must_use] + pub fn pauli_with_angle_scaling(&self) -> Option { + if self.is_plain_pauli_except_angle_and_emission() { + let (p_prep, p_meas_0, p_meas_1, p1, p2) = self.resolved_base_probabilities(); + let (p1_emission, p2_emission) = self.resolved_emission_ratios(); + Some(( + p_prep, + p_meas_0, + p_meas_1, + p1, + p2, + self.resolved_angle_scaling(), + p1_emission, + p2_emission, + )) + } else { + None + } + } + + /// True when every non-Pauli feature is off EXCEPT possibly the + /// angle-dependent two-qubit scaling and the spontaneous-emission ratios. + /// Shared by `simple_probabilities` (which additionally requires both the + /// angle scaling unset and emission explicitly off) and + /// `pauli_with_angle_scaling` (which extracts them). The emission DISTRIBUTION + /// must still be the default uniform model (`p1/p2_emission_model` unset) -- + /// custom emission samplers are NOT in this subset. + fn is_plain_pauli_except_angle_and_emission(&self) -> bool { + let explicitly_zero = |v: Option| v == Some(0.0); + let zero_or_unset = |v: Option| v.is_none() || v == Some(0.0); + let one_or_unset = |v: Option| v.is_none() || v == Some(1.0); + + // Non-neutral model defaults: unset means the default applies, so + // these must be explicitly zeroed for the physics to be plain Pauli. + // (Emission ratios are intentionally NOT required off here -- they are + // handled separately, since neo now matches engines' gate-removing + // emission with the default uniform distribution.) + let defaulted_features_off = + explicitly_zero(self.p_prep_leak_ratio) && explicitly_zero(self.p_idle_linear_rate); + + // Neutral model defaults: unset is fine. + let optional_features_off = zero_or_unset(self.p_idle_quadratic_rate) + && zero_or_unset(self.p_prep_crosstalk) + && zero_or_unset(self.p2_idle) + && zero_or_unset(self.p_meas_crosstalk_global) + && zero_or_unset(self.p_meas_crosstalk_local); + + // Custom samplers/models could change the Pauli distribution; the + // model defaults are uniform, so unset is standard. (Angle scaling is + // intentionally NOT required here — it is handled separately.) + let custom_models_off = self.p_idle_linear_model.is_none() + && self.p1_emission_model.is_none() + && self.p1_pauli_model.is_none() + && self.p2_emission_model.is_none() + && self.p2_pauli_model.is_none() + && self.p_meas_crosstalk_model.is_none(); + + let scales_neutral = one_or_unset(self.scale) + && one_or_unset(self.idle_scale) + && one_or_unset(self.prep_scale) + && one_or_unset(self.meas_scale) + && one_or_unset(self.p1_scale) + && one_or_unset(self.p2_scale) + && one_or_unset(self.p_prep_crosstalk_scale) + && one_or_unset(self.p_meas_crosstalk_scale) + // `emission_scale` multiplies the emission ratios in `build()` + // (`scale_parameters`), but the resolved ratios surfaced here are + // RAW. Require it neutral so a non-unit scale falls out of the + // subset and is rejected, rather than silently mapping the + // un-scaled ratio to neo (cross-stack mismatch). + && one_or_unset(self.emission_scale); + + let gates_default = self.noiseless_gates.as_ref().is_none_or(BTreeSet::is_empty); + + defaulted_features_off + && optional_features_off + && custom_models_off + && scales_neutral + && gates_default + } + + /// Resolve the base Pauli probabilities `(p_prep, p_meas_0, p_meas_1, p1, + /// p2)`, filling unset values from `GeneralNoiseModel::default()` so they + /// cannot drift from the model's own defaults. + fn resolved_base_probabilities(&self) -> (f64, f64, f64, f64, f64) { + let (d_prep, d_meas_0, d_meas_1, d_p1, d_p2, _) = + GeneralNoiseModel::default().probabilities(); + ( + self.p_prep.unwrap_or(d_prep), + self.p_meas_0.unwrap_or(d_meas_0), + self.p_meas_1.unwrap_or(d_meas_1), + self.p1.unwrap_or(d_p1), + self.p2.unwrap_or(d_p2), + ) + } + + /// The configured angle-dependent two-qubit scaling `(a, b, c, d, power)`, + /// or `None` when no `p2_angle_*` parameter is set. Unset components take + /// the model default (read from `GeneralNoiseModel::default()` so they + /// cannot drift). + fn resolved_angle_scaling(&self) -> Option<(f64, f64, f64, f64, f64)> { + if self.p2_angle_params.is_none() && self.p2_angle_power.is_none() { + return None; + } + let default = GeneralNoiseModel::default(); + let (a, b, c, d) = self + .p2_angle_params + .unwrap_or_else(|| default.p2_angle_params()); + let power = self + .p2_angle_power + .unwrap_or_else(|| default.p2_angle_power()); + Some((a, b, c, d, power)) + } + + /// The resolved `(p1, p2)` spontaneous-emission ratios, taking the model + /// default (read from `GeneralNoiseModel::default()`) for unset values. + /// Only meaningful alongside the default uniform emission distribution + /// (enforced by [`Self::is_plain_pauli_except_angle_and_emission`]). + fn resolved_emission_ratios(&self) -> (f64, f64) { + let default = GeneralNoiseModel::default(); + ( + self.p1_emission_ratio + .unwrap_or_else(|| default.p1_emission_ratio()), + self.p2_emission_ratio + .unwrap_or_else(|| default.p2_emission_ratio()), + ) + } + // scaling // ========================================================================================== // @@ -815,3 +1024,193 @@ impl crate::noise::IntoNoiseModel for GeneralNoiseModelBuilder { Box::new(self.build()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_probabilities_requires_explicit_zeros_for_defaulted_features() { + // Bare builder: model defaults include emission 0.5, prep leak 0.5, + // idle 0.001 — physics beyond the simple Pauli subset. + assert!( + GeneralNoiseModelBuilder::new() + .simple_probabilities() + .is_none() + ); + // Setting only a probability does not neutralize the defaults. + assert!( + GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .simple_probabilities() + .is_none() + ); + } + + #[test] + fn simple_probabilities_returns_stored_convention_values() { + let simple = GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .with_average_p2_probability(0.4) + .with_prep_probability(0.01) + .with_meas_0_probability(0.02) + .with_meas_1_probability(0.03) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .simple_probabilities() + .expect("fully zeroed config is simple"); + + let (p_prep, p_meas_0, p_meas_1, p1, p2) = simple; + assert!((p_prep - 0.01).abs() < 1e-12); + assert!((p_meas_0 - 0.02).abs() < 1e-12); + assert!((p_meas_1 - 0.03).abs() < 1e-12); + // Stored in standard depolarizing convention: average x 1.5 / x 1.25. + assert!((p1 - 0.3).abs() < 1e-12); + assert!((p2 - 0.5).abs() < 1e-12); + } + + #[test] + fn simple_probabilities_unset_probabilities_take_model_defaults() { + let simple = GeneralNoiseModelBuilder::new() + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .simple_probabilities() + .expect("zeroed features with default probabilities is simple"); + + let (d_prep, d_meas_0, d_meas_1, d_p1, d_p2, _) = + GeneralNoiseModel::default().probabilities(); + assert_eq!(simple, (d_prep, d_meas_0, d_meas_1, d_p1, d_p2)); + } + + /// The angle-aware extractor returns the same base probabilities as + /// `simple_probabilities` with `None` angle when no `p2_angle_*` is set. + #[test] + fn pauli_with_angle_scaling_matches_simple_when_no_angle() { + let builder = GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .with_average_p2_probability(0.4) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + + let simple = builder.simple_probabilities().expect("simple config"); + let (p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission, p2_emission) = builder + .pauli_with_angle_scaling() + .expect("simple config is also pauli-with-angle"); + assert_eq!((p_prep, p_meas_0, p_meas_1, p1, p2), simple); + assert!(angle.is_none()); + // Emission was explicitly zeroed to land in the strict simple subset. + assert_eq!((p1_emission, p2_emission), (0.0, 0.0)); + } + + /// Setting angle parameters keeps the config out of the strict simple + /// subset but inside the angle-aware subset, with the coefficients and + /// power surfaced verbatim. + #[test] + fn pauli_with_angle_scaling_extracts_configured_angle() { + let builder = GeneralNoiseModelBuilder::new() + .with_p2_probability(0.3) + .with_p2_angle_params(1.5, 0.0, 1.0, 0.0) + .with_p2_angle_power(2.0) + .with_average_p1_probability(0.0) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0); + + // Angle scaling is outside the STRICT simple subset. + assert!(builder.simple_probabilities().is_none()); + + let (_, _, _, _, p2, angle, _, _) = builder + .pauli_with_angle_scaling() + .expect("plain Pauli plus angle is in the angle-aware subset"); + assert!((p2 - 0.3).abs() < 1e-12); + assert_eq!(angle, Some((1.5, 0.0, 1.0, 0.0, 2.0))); + } + + /// An unset power takes the model default rather than dropping the angle. + #[test] + fn pauli_with_angle_scaling_fills_unset_power_from_default() { + let builder = GeneralNoiseModelBuilder::new() + .with_p2_probability(0.3) + .with_p2_angle_params(1.5, 0.0, 1.0, 0.0) + .with_average_p1_probability(0.0) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0); + + let (_, _, _, _, _, angle, _, _) = builder + .pauli_with_angle_scaling() + .expect("angle-aware subset"); + let default_power = GeneralNoiseModel::default().p2_angle_power(); + assert_eq!(angle, Some((1.5, 0.0, 1.0, 0.0, default_power))); + } + + /// Non-angle features beyond the subset still force `None`, even with an + /// angle configured. + #[test] + fn pauli_with_angle_scaling_rejects_non_angle_features() { + // Prep-leakage and linear idling keep their (non-zero) model defaults + // because they are never explicitly zeroed -> beyond the subset. + // (Emission ratios are NOT a blocker -- they are part of the subset.) + let builder = GeneralNoiseModelBuilder::new() + .with_p2_probability(0.3) + .with_p2_angle_params(1.5, 0.0, 1.0, 0.0); + assert!(builder.pauli_with_angle_scaling().is_none()); + } + + /// Emission ratios are surfaced verbatim when they are in the subset, so a + /// downstream stack can reproduce engines' gate-removing emission channel. + #[test] + fn pauli_with_angle_scaling_extracts_emission_ratios() { + let builder = GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .with_average_p2_probability(0.4) + .with_p1_emission_ratio(0.25) + .with_p2_emission_ratio(0.75) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + + // Non-zero emission is OUTSIDE the strict simple subset... + assert!(builder.simple_probabilities().is_none()); + // ...but inside the angle-aware subset, with the ratios surfaced. + let (.., angle, p1_emission, p2_emission) = builder + .pauli_with_angle_scaling() + .expect("plain Pauli plus emission is in the subset"); + assert!(angle.is_none()); + assert!((p1_emission - 0.25).abs() < 1e-12); + assert!((p2_emission - 0.75).abs() < 1e-12); + } + + /// A non-unit `emission_scale` multiplies the emission ratios at `build()`, + /// but the subset surfaces the RAW ratios. It must therefore be rejected + /// from the subset rather than silently mapping the un-scaled ratio. + #[test] + fn pauli_with_angle_scaling_rejects_emission_scale() { + let builder = GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .with_p1_emission_ratio(0.25) + .with_emission_scale(2.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + + // The built model applies the scale (0.25 * 2.0 = 0.5)... + let built = builder.clone().build(); + assert!((built.p1_emission_ratio() - 0.5).abs() < 1e-12); + // ...so surfacing the raw 0.25 would be a cross-stack mismatch: the + // subset must refuse it. + assert!(builder.pauli_with_angle_scaling().is_none()); + } +} diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index 9039080cb..b71315550 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -77,6 +77,29 @@ impl ChannelDispatch for DensityMatrix { /// Process a `ByteMessage` against any Clifford-capable simulator. /// +/// Capacity guard: an emitted qubit id past the simulator's size (e.g. a +/// program allocating per loop iteration beyond the configured qubit +/// count) must fail with the op and the capacity named, not deep inside +/// the simulator. Called for every dispatched command, INCLUDING commands +/// consumed by MZ-batching lookahead. +fn check_qubit_capacity( + gate_type: pecos_core::gate_type::GateType, + qubits: &[QubitId], + capacity: usize, +) -> Result<(), PecosError> { + for q in qubits { + if q.0 >= capacity { + return Err(PecosError::Generic(format!( + "quantum op {gate_type:?} targets qubit {} but the simulator holds \ + {capacity} qubits (dynamic allocation exceeded the configured \ + capacity; raise the builder's qubit count)", + q.0 + ))); + } + } + Ok(()) +} + /// Shared gate dispatch for `SparseStabEngine`, `StabilizerEngine`, etc. /// Supports Clifford gates, preparations, measurements, and Clifford rotations /// (non-Clifford angles produce an error via `CliffordRotation::try_*`). @@ -92,6 +115,7 @@ fn process_clifford_message { @@ -188,6 +212,12 @@ fn process_clifford_message { @@ -540,6 +571,12 @@ fn process_general_message< ) { cmd_idx += 1; + // Lookahead-consumed commands need the guard too. + check_qubit_capacity( + batch[cmd_idx].gate_type, + &batch[cmd_idx].qubits, + sim.num_qubits(), + )?; mz_qubits.extend_from_slice(&batch[cmd_idx].qubits); } let meas_ids = sim.mz(&mz_qubits); diff --git a/crates/pecos-engines/src/sampling.rs b/crates/pecos-engines/src/sampling.rs new file mode 100644 index 000000000..40b6c19b2 --- /dev/null +++ b/crates/pecos-engines/src/sampling.rs @@ -0,0 +1,163 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Cross-stack sampling vocabulary. +//! +//! [`monte_carlo()`] builds a [`MonteCarloBuilder`] -- the stack-agnostic +//! run-spec for Monte Carlo sampling (shot count plus optional worker +//! parallelism). It is the single source of truth shared by both simulation +//! stacks: the engines [`MonteCarloEngine`](crate::MonteCarloEngine) consumes +//! it directly, and the `pecos-neo` stack converts it into its own `Sampling` +//! strategy. The unified facade (`pecos::sim().stack(...).sampling(...)`) and +//! the neo builder (`sim_neo().sampling(...)`) therefore accept the SAME +//! `monte_carlo(n).workers(m)` spelling. +//! +//! Monte Carlo is the only strategy both stacks share; richer rare-event +//! strategies (importance sampling, subset simulation) are `pecos-neo`-only and +//! live there. + +use std::num::NonZero; + +/// Builder for the Monte Carlo sampling strategy. +/// +/// Created by [`monte_carlo()`]. The shot count is the defining argument; +/// worker parallelism is optional and unset by default (sequential). Worker +/// resolution is deferred to [`resolved_workers()`](Self::resolved_workers) so +/// an unset count does not silently override a worker count configured +/// elsewhere on a builder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MonteCarloBuilder { + shots: usize, + /// Explicit worker count, or `None` when unset. `Some` from `.workers(n)`. + workers: Option, + /// `true` from `.auto_workers()`: resolve to available parallelism at use. + auto_workers: bool, +} + +impl MonteCarloBuilder { + /// Set the number of parallel workers. + /// + /// Parallel execution distributes shots across workers, each with its own + /// simulator, command source, and noise model built from the shared + /// configuration. Per-shot seeding uses global shot indices on the neo + /// stack, so neo results are identical for any worker count (the engines + /// stack does not make that guarantee). + #[must_use] + pub fn workers(mut self, workers: usize) -> Self { + self.workers = Some(workers); + self.auto_workers = false; + self + } + + /// Request a worker count derived from the machine's available parallelism, + /// resolved when the spec is consumed (see [`workers()`](Self::workers)). + #[must_use] + pub fn auto_workers(mut self) -> Self { + self.auto_workers = true; + self + } + + /// The configured shot count. + #[must_use] + pub fn shots(&self) -> usize { + self.shots + } + + /// The explicitly-set worker count, or `None` when unset. + /// + /// `None` means neither `.workers(n)` nor `.auto_workers()` was called; + /// callers that need a concrete count should use + /// [`resolved_workers()`](Self::resolved_workers). + #[must_use] + pub fn worker_count(&self) -> Option { + self.workers + } + + /// Whether [`auto_workers()`](Self::auto_workers) was requested. + #[must_use] + pub fn auto_workers_requested(&self) -> bool { + self.auto_workers + } + + /// The concrete worker count: available parallelism when `.auto_workers()` + /// was requested, the explicit `.workers(n)` otherwise, and `1` when unset. + #[must_use] + pub fn resolved_workers(&self) -> usize { + if self.auto_workers { + std::thread::available_parallelism().map_or(1, NonZero::get) + } else { + self.workers.unwrap_or(1) + } + } +} + +/// Create a Monte Carlo sampling spec running `shots` shots. +/// +/// This is the standard execution strategy: each shot runs the program once +/// and records its outcomes. Sequential by default; add +/// [`workers(n)`](MonteCarloBuilder::workers) or +/// [`auto_workers()`](MonteCarloBuilder::auto_workers) for parallel execution. +#[must_use] +pub fn monte_carlo(shots: usize) -> MonteCarloBuilder { + MonteCarloBuilder { + shots, + workers: None, + auto_workers: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monte_carlo_defaults_to_sequential_unset_workers() { + let mc = monte_carlo(100); + assert_eq!(mc.shots(), 100); + assert_eq!(mc.worker_count(), None); + assert!(!mc.auto_workers_requested()); + // Unset resolves to a single worker. + assert_eq!(mc.resolved_workers(), 1); + } + + #[test] + fn workers_sets_explicit_count() { + let mc = monte_carlo(100).workers(8); + assert_eq!(mc.worker_count(), Some(8)); + assert_eq!(mc.resolved_workers(), 8); + assert!(!mc.auto_workers_requested()); + } + + #[test] + fn auto_workers_resolves_to_available_parallelism() { + let mc = monte_carlo(100).auto_workers(); + assert!(mc.auto_workers_requested()); + assert_eq!(mc.worker_count(), None); + let expected = std::thread::available_parallelism().map_or(1, NonZero::get); + assert_eq!(mc.resolved_workers(), expected); + } + + #[test] + fn last_worker_setter_wins() { + // auto then explicit -> explicit + assert_eq!( + monte_carlo(1).auto_workers().workers(3).resolved_workers(), + 3 + ); + // explicit then auto -> auto + let auto = std::thread::available_parallelism().map_or(1, NonZero::get); + assert_eq!( + monte_carlo(1).workers(3).auto_workers().resolved_workers(), + auto + ); + } +} diff --git a/crates/pecos-engines/src/shot_results.rs b/crates/pecos-engines/src/shot_results.rs index 1cd7951e1..12455d9b8 100644 --- a/crates/pecos-engines/src/shot_results.rs +++ b/crates/pecos-engines/src/shot_results.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at @@ -12,191 +12,61 @@ //! Shot results and data structures for quantum program execution. //! -//! This module provides comprehensive data structures for storing and manipulating -//! the results of quantum program executions. It includes: -//! -//! - **Data Types**: The `Data` enum for flexible value storage -//! - **Single Results**: The `Shot` struct for individual execution results -//! - **Collections**: The `ShotVec` struct for multiple executions -//! - **Columnar Analysis**: The `ShotMap` struct for efficient analysis -//! - **Formatting**: Display and export utilities -//! -//! # Design Philosophy -//! -//! The module is designed around the following principles: -//! - **Flexibility**: Support for diverse data types and quantum backends -//! - **Efficiency**: Optimized for common operations like analysis and export -//! - **Compatibility**: Easy conversion between row-based and columnar formats -//! - **Extensibility**: JSON support for custom and complex data -//! -//! # Main Types -//! -//! ## `Data` - Flexible Value Storage -//! ``` -//! use pecos_engines::shot_results::Data; -//! use bitvec::prelude::*; -//! -//! // Support for various numeric types -//! let measurement = Data::U32(42); -//! let phase = Data::F64(3.14159); -//! -//! // BitVec for quantum register results -//! let mut bits = BitVec::::new(); -//! bits.push(true); -//! bits.push(false); -//! let register = Data::BitVec(bits); -//! ``` -//! -//! ## `Shot` - Single Execution Results -//! ``` -//! use pecos_engines::shot_results::{Shot, Data}; -//! -//! let mut shot = Shot::default(); -//! shot.add_register("qubits", 5, 3); // 3-bit register with value 5 -//! shot.data.insert("error_rate".to_string(), Data::F64(0.001)); -//! ``` -//! -//! ## `ShotVec` - Multiple Executions -//! ``` -//! use pecos_engines::shot_results::{ShotVec, Shot}; -//! -//! let mut results = ShotVec::new(); -//! for i in 0..100 { -//! let mut shot = Shot::default(); -//! shot.add_register("measurement", i % 8, 3); -//! results.shots.push(shot); -//! } -//! -//! // Convert to JSON for export -//! let json = results.to_compact_json(); -//! ``` -//! -//! ## `ShotMap` - Columnar Analysis -//! ``` -//! # use pecos_engines::shot_results::{ShotVec, Shot}; -//! # let mut results = ShotVec::new(); -//! # for i in 0..100 { -//! # let mut shot = Shot::default(); -//! # shot.add_register("measurement", i % 8, 3); -//! # results.shots.push(shot); -//! # } -//! // Convert to columnar format for analysis -//! let shot_map = results.try_as_shot_map().unwrap(); -//! -//! // Efficient analysis of specific registers -//! let measurements = shot_map.try_bits_as_u64("measurement").unwrap(); -//! let average: f64 = measurements.iter().sum::() as f64 / measurements.len() as f64; -//! ``` - -#![allow(clippy::similar_names)] -// For percentage calculations below with large usize values converted to f64, -// we accept the potential precision loss since the values are used only for display -// with a single decimal place, and the precision loss would only be observable -// with extremely large shot counts (> 2^53). -#![allow(clippy::cast_precision_loss)] - -// Sub-modules -pub mod conversions; -pub mod data; -pub mod data_vec; -pub mod shot; -pub mod shot_map; -pub mod shot_map_formatter; -#[cfg(test)] -mod shot_tests; -pub mod shot_vec; - -// Re-export all public types for backward compatibility -pub use data::Data; -pub use data_vec::DataVec; -pub use shot::Shot; -pub use shot_map::ShotMap; -pub use shot_map_formatter::{ - BitVecDisplayFormat, ShotMapDisplay, ShotMapDisplayExt, ShotMapDisplayOptions, -}; -pub use shot_vec::ShotVec; - -// Re-export for tests and benchmarks that may reference the full module path -#[cfg(test)] -#[allow(clippy::similar_names)] -mod tests { - use super::*; - - #[test] - fn test_shot_results_display_64bit() { - // Create a shot with various data types - let mut shot1 = Shot::default(); - shot1.data.insert("reg_32".to_string(), Data::U32(42)); - - // Add a large 64-bit register (larger than u32::MAX) - let large_value = 1u64 << 34; // 2^34 = 17,179,869,184 (>4B) - shot1 - .data - .insert("reg_64".to_string(), Data::U64(large_value)); +//! The result types live in the neutral [`pecos_results`] crate so that any +//! simulation stack can produce them; this module re-exports them under the +//! historical `pecos_engines::shot_results` paths and adds the +//! `ByteMessage`-protocol conveniences that belong to this crate. + +pub use pecos_results::*; + +use crate::byte_message::ByteMessage; +use pecos_core::errors::PecosError; +use std::collections::BTreeMap; + +/// Create a [`Shot`] directly from a [`ByteMessage`] containing measurement +/// results, mapping result IDs to names via `result_id_to_name` (missing IDs +/// fall back to `result_{id}`). +/// +/// # Errors +/// +/// Returns an error if the `ByteMessage` cannot be parsed or doesn't contain +/// valid measurement results. +pub fn shot_from_byte_message( + message: &ByteMessage, + result_id_to_name: &BTreeMap, +) -> Result { + let outcomes = message.outcomes()?; + + let mut result = Shot::default(); + for (result_id, value) in outcomes.into_iter().enumerate() { + let name = result_id_to_name + .get(&result_id) + .cloned() + .unwrap_or_else(|| format!("result_{result_id}")); + result.data.insert(name, Data::U32(value)); + } - // Add a signed 64-bit register with negative value - shot1.data.insert("reg_signed".to_string(), Data::I64(-42)); + Ok(result) +} - // Add some floating point data - shot1 +/// Create a single-shot [`ShotVec`] directly from a [`ByteMessage`] +/// containing measurement results, naming each outcome `result_{id}`. +/// +/// # Errors +/// +/// Returns a `PecosError` if the measurements cannot be extracted from the +/// `ByteMessage`. +pub fn shot_vec_from_byte_message(message: &ByteMessage) -> Result { + let outcomes = message.outcomes()?; + + let mut shot_result = Shot::default(); + for (result_id, value) in outcomes.into_iter().enumerate() { + shot_result .data - .insert("float_val".to_string(), Data::F64(std::f64::consts::PI)); - - // Create ShotVec with one shot - let shot_results = ShotVec { shots: vec![shot1] }; - - // Convert to string - let json_string = shot_results.to_compact_json(); - let display_string = format!("{shot_results}"); - - // The display string should match the compact JSON string - assert_eq!(display_string, json_string); - - // Verify that both are valid JSON and contain the same data - let json_value1: serde_json::Value = serde_json::from_str(&display_string).unwrap(); - let json_value2: serde_json::Value = serde_json::from_str(&json_string).unwrap(); - - // Verify that both are arrays with the same length - assert_eq!( - json_value1.as_array().unwrap().len(), - json_value2.as_array().unwrap().len(), - "JSON arrays should have the same number of shots" - ); - - // Verify that all registers appear in the JSON - assert!(json_string.contains("\"reg_32\"")); - assert!(json_string.contains("42")); - assert!(json_string.contains("\"reg_64\"")); - assert!(json_string.contains("17179869184")); - assert!(json_string.contains("\"reg_signed\"")); - assert!(json_string.contains("-42")); - assert!(json_string.contains("\"float_val\"")); - assert!(json_string.contains("3.14159")); + .insert(format!("result_{result_id}"), Data::U32(value)); } - #[test] - fn test_module_integration() { - // Test that all modules work together correctly - let mut shot_vec = ShotVec::new(); - - for i in 0..5 { - let mut shot = Shot::default(); - shot.add_register("qubits", i, 3); - shot.data - .insert("phase".to_string(), Data::F64(f64::from(i) * 0.1)); - shot_vec.shots.push(shot); - } - - // Convert to ShotMap - let shot_map = shot_vec.try_as_shot_map().unwrap(); - - // Test data access - assert_eq!(shot_map.num_shots(), 5); - assert_eq!(shot_map.num_registers(), 2); // qubits + phase (width metadata filtered out) - - // Test formatting - let display_output = format!("{}", shot_map.display()); - assert!(display_output.contains("\"qubits\"")); - assert!(display_output.contains("\"phase\"")); - } + Ok(ShotVec { + shots: vec![shot_result], + }) } diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index a858e94ef..bcd1b9142 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -265,7 +265,7 @@ impl SimBuilder { use crate::quantum::SparseStabEngine; // Build classical engine (required) - let classical_engine = match self.classical_builder { + let mut classical_engine = match self.classical_builder { Some(builder) => builder.build_boxed()?, None => { return Err(PecosError::Input( @@ -284,13 +284,47 @@ impl SimBuilder { ) })?; + // Forward a qubit-count hint only when it is meaningful. An explicit + // count is the caller's choice and is always forwarded (even 0). But an + // inferred 0 from a dynamic classical engine (e.g. the QIS/Selene + // runtime, which reports 0 qubits until program execution discovers its + // allocations) means "unknown", not "zero qubits": freezing it would + // override the runtime's own capacity discovery and initialize the + // plugin with no qubits, so every qalloc fails. Keep that distinction. + match self.explicit_num_qubits { + Some(explicit) => classical_engine.set_num_qubits_hint(explicit), + None if num_qubits > 0 => classical_engine.set_num_qubits_hint(num_qubits), + None => {} + } + // Build quantum engine (require explicit qubit specification) let quantum_engine = if let Some(mut builder) = self.quantum_builder { // Set qubits on the quantum engine builder if explicitly specified builder.set_qubits_if_needed(num_qubits); builder.build_boxed()? } else { - // Default: sparse stabilizer + // Default: fixed-size sparse stabilizer. It does NOT grow, so a + // 0-qubit default is only correct for a program that genuinely uses + // zero qubits (e.g. a classical-only QASM program, which reports a + // STATIC 0). A dynamic engine (e.g. QIS) instead reports 0 *before + // execution* and then allocates qubits at runtime; building a 0-qubit + // fixed engine for it would panic on the first allocation. Reject only + // that specific case -- no explicit count, no quantum engine, and a + // dynamic-unknown-zero classical engine -- and ask for an explicit + // `.qubits(n)` or a `.quantum(...)` engine. Genuinely-0-qubit and + // explicit-count programs are unaffected. + if self.explicit_num_qubits.is_none() + && num_qubits == 0 + && classical_engine.has_dynamic_qubit_count() + { + return Err(PecosError::Input( + "A dynamic classical engine reports 0 qubits before execution, but the \ + default quantum engine is fixed-size and cannot grow to fit qubits \ + allocated at runtime. Specify .qubits(n) or provide a quantum engine \ + via .quantum()." + .to_string(), + )); + } Box::new(SparseStabEngine::new(num_qubits)) }; diff --git a/crates/pecos-engines/tests/qubit_capacity_test.rs b/crates/pecos-engines/tests/qubit_capacity_test.rs new file mode 100644 index 000000000..069ac5e15 --- /dev/null +++ b/crates/pecos-engines/tests/qubit_capacity_test.rs @@ -0,0 +1,111 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License.You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Integration tests for the per-command qubit-capacity guard. +//! +//! A command targeting a qubit index at or beyond the simulator's capacity +//! (e.g. dynamic allocation past the configured qubit count) must fail with +//! the op and the capacity named -- through BOTH shared dispatchers +//! (`SparseStabEngine` -> Clifford dispatch, `StabVecEngine` -> general +//! dispatch), and INCLUDING commands consumed by the MZ-batching lookahead, +//! which bypasses the top-of-loop check. +//! +//! (`StateVecEngine` is NOT covered: it has its own dispatch loop that +//! auto-grows the simulator instead of rejecting -- pre-existing behavior +//! for static-circuit flows whose qubit count is inferred as 0.) + +use pecos_engines::Engine; +use pecos_engines::byte_message::ByteMessageBuilder; +use pecos_engines::quantum::{SparseStabEngine, StabVecEngine}; + +fn build_message(build: impl FnOnce(&mut ByteMessageBuilder)) -> pecos_engines::ByteMessage { + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_quantum_operations(); + build(&mut builder); + builder.build() +} + +fn assert_capacity_error(result: Result) { + match result { + Err(e) => { + let msg = e.to_string(); + assert!( + msg.contains("2 qubits"), + "error should name the capacity: {msg}" + ); + assert!( + msg.contains("qubit 2"), + "error should name the offending qubit: {msg}" + ); + } + Ok(_) => panic!("expected out-of-capacity command to fail"), + } +} + +#[test] +fn clifford_dispatch_rejects_gate_beyond_capacity() { + let mut engine = SparseStabEngine::new(2); + let msg = build_message(|b| { + b.h(&[2]); + }); + assert_capacity_error(engine.process(msg)); +} + +#[test] +fn general_dispatch_rejects_gate_beyond_capacity() { + let mut engine = StabVecEngine::new(2); + let msg = build_message(|b| { + b.h(&[2]); + }); + assert_capacity_error(engine.process(msg)); +} + +#[test] +fn clifford_dispatch_rejects_mz_lookahead_beyond_capacity() { + // Two consecutive MZ commands batch via lookahead; the SECOND one is + // consumed inside the batching loop, not at the top of the dispatch + // loop, and must still be guarded. + let mut engine = SparseStabEngine::new(2); + let msg = build_message(|b| { + b.mz(&[0]); + b.mz(&[2]); + }); + assert_capacity_error(engine.process(msg)); +} + +#[test] +fn general_dispatch_rejects_mz_lookahead_beyond_capacity() { + let mut engine = StabVecEngine::new(2); + let msg = build_message(|b| { + b.mz(&[0]); + b.mz(&[2]); + }); + assert_capacity_error(engine.process(msg)); +} + +#[test] +fn in_range_commands_still_process() { + let mut engine = SparseStabEngine::new(2); + let msg = build_message(|b| { + b.h(&[0]); + b.cx(&[(0, 1)]); + b.mz(&[0]); + b.mz(&[1]); + }); + let outcomes = engine + .process(msg) + .expect("in-range circuit should process") + .outcomes() + .expect("outcomes should parse"); + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0], outcomes[1], "Bell pair outcomes must agree"); +} diff --git a/crates/pecos-fusion-blossom/src/core_traits.rs b/crates/pecos-fusion-blossom/src/core_traits.rs index ee44d32b4..aacddedcf 100644 --- a/crates/pecos-fusion-blossom/src/core_traits.rs +++ b/crates/pecos-fusion-blossom/src/core_traits.rs @@ -37,11 +37,12 @@ impl Decoder for FusionBlossomDecoder { /// /// Uses the fast decode path with pre-computed observable bitmasks. impl pecos_decoder_core::ObservableDecoder for FusionBlossomDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { self.decode_to_obs_mask(syndrome) + .map(pecos_decoder_core::obs_mask::ObsMask::from_u64) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string())) } } diff --git a/crates/pecos-fusion-blossom/src/decoder.rs b/crates/pecos-fusion-blossom/src/decoder.rs index ad05cd961..547e4d8b3 100644 --- a/crates/pecos-fusion-blossom/src/decoder.rs +++ b/crates/pecos-fusion-blossom/src/decoder.rs @@ -282,6 +282,11 @@ impl FusionBlossomDecoder { /// /// Returns error if the graph is empty or construction fails. pub fn from_matching_graph(graph: &pecos_decoder_core::dem::DemMatchingGraph) -> Result { + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in the `1 << o` loop below. + graph + .ensure_observables_fit_u64() + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -368,6 +373,16 @@ impl FusionBlossomDecoder { parsed: &ParsedCorrelatedDem, weight_factors: Option<&[f64]>, ) -> Result { + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in build_obs_masks. + if parsed.num_observables > 64 { + return Err(FusionBlossomError::Configuration(format!( + "this matching decoder packs observables into a u64 and supports at most 64 \ + observables, but the DEM has {}; use the 'pymatching' decoder or \ + LogicalSubgraphDecoder for wider observable sets", + parsed.num_observables + ))); + } let config = FusionBlossomConfig { num_nodes: Some(parsed.num_detectors), num_observables: parsed.num_observables, @@ -446,6 +461,10 @@ impl FusionBlossomDecoder { dcm: &pecos_decoder_core::dem::DemCheckMatrix, weight_factors: Option<&[f64]>, ) -> Result { + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in build_obs_masks. + dcm.ensure_observables_fit_u64() + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; // Use Legacy solver which tolerates duplicate edges (no assertion). let config = FusionBlossomConfig { num_nodes: Some(dcm.num_detectors), @@ -511,6 +530,10 @@ impl FusionBlossomDecoder { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in build_obs_masks. + dcm.ensure_observables_fit_u64() + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(dcm.num_detectors), @@ -659,6 +682,20 @@ impl FusionBlossomDecoder { Ok(decoder) } + /// Fail loud if any observable index is `>= 64`: this decoder packs + /// observable flips into a `u64` (`1 << index`) in `build_obs_masks`, so a + /// wider index would overflow-panic. Reject it where observables enter the + /// decoder (every edge-construction path) rather than at the later shift. + fn check_observable_indices(observables: &[usize]) -> Result<()> { + if let Some(&o) = observables.iter().find(|&&o| o >= 64) { + return Err(FusionBlossomError::InvalidGraph(format!( + "observable index {o} exceeds the 64 this decoder packs into a u64; use the \ + 'pymatching' decoder or LogicalSubgraphDecoder for wider observable sets" + ))); + } + Ok(()) + } + /// Add an edge to the graph /// /// # Errors @@ -666,6 +703,7 @@ impl FusionBlossomDecoder { /// Returns [`FusionBlossomError::InvalidGraph`] if: /// - Either node index is out of bounds /// - The weight is negative + /// - Any observable index is `>= 64` pub fn add_edge( &mut self, node1: usize, @@ -681,6 +719,7 @@ impl FusionBlossomDecoder { self.num_nodes - 1 ))); } + Self::check_observable_indices(observables)?; let weight_int = if let Some(w) = weight { if w < 0.0 { @@ -731,6 +770,7 @@ impl FusionBlossomDecoder { self.num_nodes - 1 ))); } + Self::check_observable_indices(observables)?; // Create a virtual boundary node if not already created if self.boundary_node.is_none() { diff --git a/crates/pecos-hugr-qis/Cargo.toml b/crates/pecos-hugr-qis/Cargo.toml index f6c4a6937..1a6f641a6 100644 --- a/crates/pecos-hugr-qis/Cargo.toml +++ b/crates/pecos-hugr-qis/Cargo.toml @@ -24,6 +24,9 @@ pecos-core = { workspace = true, features = ["anyhow"] } # LLVM features are required for this crate's core functionality tket = { workspace = true, features = ["llvm"] } tket-qsystem = { workspace = true, features = ["llvm"] } +# `hugr-llvm` only exposes plain `llvm21-1`; this keeps its transitive +# inkwell/llvm-sys build preferring libLLVM when a shared LLVM is available. +inkwell.workspace = true [features] default = [] diff --git a/crates/pecos-hugr-qis/src/array.rs b/crates/pecos-hugr-qis/src/array.rs index 772b8a979..6ddd3b73d 100644 --- a/crates/pecos-hugr-qis/src/array.rs +++ b/crates/pecos-hugr-qis/src/array.rs @@ -21,10 +21,8 @@ impl ArrayCodegen for SeleneHeapArrayCodegen { size: IntValue<'c>, ) -> Result> { let iw_ctx = ctx.typing_session().iw_context(); - let malloc_sig = iw_ctx - .i8_type() - .ptr_type(AddressSpace::default()) - .fn_type(&[iw_ctx.i64_type().into()], false); + let ptr_ty = iw_ctx.ptr_type(AddressSpace::default()); + let malloc_sig = ptr_ty.fn_type(&[iw_ctx.i64_type().into()], false); let malloc = ctx.get_extern_func("heap_alloc", malloc_sig)?; let res = ctx .builder() @@ -40,7 +38,7 @@ impl ArrayCodegen for SeleneHeapArrayCodegen { ptr: PointerValue<'c>, ) -> Result<()> { let iw_ctx = ctx.typing_session().iw_context(); - let ptr_ty = iw_ctx.i8_type().ptr_type(AddressSpace::default()); + let ptr_ty = iw_ctx.ptr_type(AddressSpace::default()); let ptr = ctx.builder().build_bit_cast(ptr, ptr_ty, "")?; let free_sig = iw_ctx.void_type().fn_type(&[ptr_ty.into()], false); diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 91bf4eb0d..f24bc02dc 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -37,13 +37,15 @@ enum ExactlyOneError { } use tket::hugr::envelope::EnvelopeConfig; use tket::hugr::llvm::extension::int::IntCodegenExtension; +use tket::hugr::llvm::inkwell::AddressSpace; use tket::hugr::llvm::inkwell::OptimizationLevel; -use tket::hugr::llvm::inkwell::context::Context; +use tket::hugr::llvm::inkwell::context::{Context, ContextRef}; use tket::hugr::llvm::inkwell::module::Module; use tket::hugr::llvm::inkwell::passes::PassBuilderOptions; use tket::hugr::llvm::inkwell::targets::{ CodeModel, InitializationConfig, RelocMode, Target, TargetMachine, TargetTriple, }; +use tket::hugr::llvm::inkwell::types::FunctionType; use tket::hugr::llvm::utils::fat::FatExt as _; use tket::hugr::llvm::{ CodegenExtsBuilder, @@ -53,19 +55,25 @@ use tket::hugr::llvm::{ use tket::hugr::ops::DataflowParent; use tket::hugr::{Hugr, HugrView, Node}; use tket::llvm::rotation::RotationCodegenExtension; -use tket_qsystem::QSystemPass; +use tket::passes::ComposablePass; use tket_qsystem::llvm::array_utils::ArrayLowering; use tket_qsystem::llvm::futures::FuturesCodegenExtension; use tket_qsystem::llvm::{ debug::DebugCodegenExtension, prelude::QISPreludeCodegen, qsystem::QSystemCodegenExtension, random::RandomCodegenExtension, result::ResultsCodegenExtension, utils::UtilsCodegenExtension, }; +use tket_qsystem::{QSystemPass, QSystemPlatform}; // Import read_hugr_envelope from utils module use crate::utils::read_hugr_envelope; const LLVM_MAIN: &str = "qmain"; const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; +const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; +const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; +const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_hugr"; +const RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubit_hugr"; +const RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubits2_hugr"; // Extension registry is defined in the parent module @@ -82,6 +90,14 @@ pub struct CompileArgs { pub target_triple: Option, /// Optimization level pub opt_level: OptimizationLevel, + /// Target `QSystem` platform for lowering and codegen. + /// + /// PECOS targets the Quantinuum Helios QIS runtime (the Selene Helios + /// plugin), so this defaults to [`QSystemPlatform::Helios`]. Set it + /// explicitly to select another supported platform such as + /// [`QSystemPlatform::Sol`]; unsupported platforms are rejected with a + /// clear error when compilation starts. + pub platform: QSystemPlatform, } impl Default for CompileArgs { @@ -92,21 +108,39 @@ impl Default for CompileArgs { save_hugr: None, target_triple: None, opt_level: OptimizationLevel::Default, + // PECOS targets the Selene Helios QIS runtime by default. + platform: QSystemPlatform::Helios, } } } +/// Reject `QSystem` platforms that PECOS has not wired through its QIS pipeline. +/// +/// [`QSystemPlatform`] is `#[non_exhaustive]`; fail loudly on any future variant +/// rather than silently lowering for a platform PECOS has not validated +/// end-to-end (codegen extensions + Selene runtime). +fn ensure_supported_platform(platform: QSystemPlatform) -> Result<()> { + match platform { + QSystemPlatform::Helios | QSystemPlatform::Sol => Ok(()), + other => Err(anyhow!( + "Unsupported QSystem platform {other:?}: pecos-hugr-qis supports Helios and Sol. \ + Wire a newer tket-qsystem platform through the QIS codegen and Selene runtime \ + before selecting it." + )), + } +} + /// Process HUGR by applying required passes. /// /// Note: `QSystemPass` internally calls `inline_constant_functions` when the /// `llvm` feature is enabled, so we don't need to call it separately. -fn process_hugr(hugr: &mut Hugr) -> Result<()> { - QSystemPass::default().run(hugr)?; +fn process_hugr(hugr: &mut Hugr, platform: QSystemPlatform) -> Result<()> { + QSystemPass::defaults(platform).run(hugr)?; Ok(()) } /// Build codegen extensions for LLVM generation -fn codegen_extensions() -> CodegenExtsMap<'static, Hugr> { +fn codegen_extensions(platform: QSystemPlatform) -> CodegenExtsMap<'static, Hugr> { use crate::array::SeleneHeapArrayCodegen; let pcg = QISPreludeCodegen; @@ -120,7 +154,7 @@ fn codegen_extensions() -> CodegenExtsMap<'static, Hugr> { .add_default_static_array_extensions() .add_default_borrow_array_extensions(pcg.clone()) .add_extension(FuturesCodegenExtension) - .add_extension(QSystemCodegenExtension::from(pcg.clone())) + .add_extension(QSystemCodegenExtension::new(platform, pcg.clone())) .add_extension(RandomCodegenExtension) .add_extension(ResultsCodegenExtension::new( SeleneHeapArrayCodegen::LOWERING, @@ -192,7 +226,7 @@ fn get_module_with_std_exts<'c>( namer: Rc, hugr: &'c mut Hugr, ) -> Result> { - process_hugr(hugr)?; + process_hugr(hugr, args.platform)?; if let Some(filename) = &args.save_hugr { let file = fs::File::create(filename)?; @@ -204,7 +238,7 @@ fn get_module_with_std_exts<'c>( namer, hugr, &args.name, - Rc::new(codegen_extensions()), + Rc::new(codegen_extensions(args.platform)), ) } @@ -331,6 +365,9 @@ fn compile<'c, 'hugr: 'c>( ctx: &'c Context, hugr: &'hugr mut Hugr, ) -> Result> { + // Fail fast before any expensive work if the platform is unsupported. + ensure_supported_platform(args.platform)?; + log::debug!("starting primary compilation"); let namer = Rc::new(Namer::new("__hugr__.", true)); @@ -343,6 +380,11 @@ fn compile<'c, 'hugr: 'c>( // Create a new LLVM module using hugr-llvm let module = get_module_with_std_exts(args, ctx, namer, hugr)?; + // Rewrite PECOS helper declarations to their public ABI symbols in the module + // itself, so both the text and bitcode outputs link against pecos-qis-ffi's + // `pecos_qis_*` exports (a text-only rewrite would miss the bitcode path). + normalize_pecos_helper_symbols_in_module(&module)?; + // Get the target machine let target_machine = if let Some(ref triple) = args.target_triple { get_target_machine_from_triple(triple, args.opt_level)? @@ -389,6 +431,136 @@ fn compile<'c, 'hugr: 'c>( Ok(module) } +/// PECOS-owned QIS runtime ABI helpers. Guppy/HUGR lowers these under a private +/// `__hugr__.*` symbol; they must be rewritten to their stable public names so the +/// compiled module links against the `pecos_qis_*` symbols `pecos-qis-ffi` exports. +const PECOS_HELPER_SYMBOLS: &[&str] = &[ + TRACE_METADATA_HUGR_SYMBOL, + TRACE_METADATA_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, +]; + +/// If `symbol` is a PECOS-owned helper lowered under the private `__hugr__.*` +/// namespace, return its stable public name; otherwise `None`. +/// +/// Guppy qualifies the symbol with the defining scope (module/function, plus a +/// `` segment for function-local declarations) and tket appends a numeric +/// node id, so the helper name sits second-from-last: +/// `__hugr__...`. +fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Option<&'a str> { + let rest = symbol.strip_prefix(HUGR_SYMBOL_PREFIX)?; + let mut parts = rest.rsplit('.'); + let _suffix = parts.next()?; + let helper_name = parts.next()?; + helper_symbols + .iter() + .copied() + .find(|helper| *helper == helper_name) +} + +/// The fixed LLVM signature each PECOS helper must have, matching the +/// `pecos-qis-ffi` ABI export (`crates/pecos-qis-ffi/src/ffi.rs`): +/// +/// - `pecos_qis_trace_metadata_hugr(*const u8, *const u8)` -> `void (ptr, ptr)` +/// - `pecos_qis_trace_metadata_qubit_hugr(i64, *const u8, *const u8) -> i64` +/// -> `i64 (i64, ptr, ptr)` +/// - `pecos_qis_runtime_barrier_qubit_hugr(i64) -> i64` -> `i64 (i64)` +/// - `pecos_qis_runtime_barrier_qubits2_hugr(i64, i64) -> QubitPair{i64,i64}` +/// -> `{ i64, i64 } (i64, i64)` +/// +/// Returns `None` only if `helper` is not a recognized PECOS helper (a programming +/// error here, since callers pass a name already matched against `PECOS_HELPER_SYMBOLS`). +fn expected_pecos_helper_type<'ctx>( + ctx: ContextRef<'ctx>, + helper: &str, +) -> Option> { + let i64t = ctx.i64_type(); + let ptr = ctx.ptr_type(AddressSpace::default()); + let ty = match helper { + TRACE_METADATA_HUGR_SYMBOL => ctx.void_type().fn_type(&[ptr.into(), ptr.into()], false), + TRACE_METADATA_QUBIT_HUGR_SYMBOL => { + i64t.fn_type(&[i64t.into(), ptr.into(), ptr.into()], false) + } + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL => i64t.fn_type(&[i64t.into()], false), + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL => ctx + .struct_type(&[i64t.into(), i64t.into()], false) + .fn_type(&[i64t.into(), i64t.into()], false), + _ => return None, + }; + Some(ty) +} + +/// Rewrite PECOS helper declarations from their private `__hugr__.*` symbol to the +/// stable public `pecos_qis_*` name, in the LLVM module itself. +/// +/// Renaming the `FunctionValue`s (rather than rewriting printed text) means every +/// output format -- LLVM IR text AND bitcode -- carries the public ABI symbol that +/// `pecos-qis-ffi` exports. It also sidesteps text-only quoting: a function-local +/// Guppy declaration's raw module symbol +/// (`__hugr__....pecos_qis_..._hugr.`) is unquoted here, so the +/// same `pecos_helper_public_name` match applies. Renaming a function updates all of +/// its call sites automatically (they reference the value, not the name). +/// +/// Because this code claims the public ABI symbol, it also enforces the ABI: every +/// recognized helper declaration is validated against its fixed `pecos-qis-ffi` +/// signature. LLVM 21 opaque pointers let a call disagree with its callee declaration's +/// type without failing `module.verify()`, so a helper declared with the wrong +/// signature -- even a lone, self-consistent one -- would otherwise silently emit a +/// call to the public symbol with the wrong ABI. We fail loud instead. This also makes +/// duplicate declarations safe to merge: any two that pass the ABI check are identical, +/// so the duplicate's uses can be redirected to the first (avoiding LLVM uniquifying a +/// second declaration to `.1`, which `pecos-qis-ffi` does not export). +fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) -> Result<()> { + let ctx = module.get_context(); + // Collect first: we rename and (on collision) delete functions below, so we must + // not hold the module's function iterator while mutating the function list. + let funcs: Vec<_> = module.get_functions().collect(); + for func in funcs { + let Some(public) = func + .get_name() + .to_str() + .ok() + .and_then(|name| pecos_helper_public_name(name, PECOS_HELPER_SYMBOLS)) + else { + continue; + }; + + // Enforce the fixed PECOS helper ABI on every recognized declaration before + // claiming the public symbol. + let Some(expected) = expected_pecos_helper_type(ctx, public) else { + return Err(anyhow!( + "internal error: no ABI signature registered for PECOS helper `{public}`" + )); + }; + if func.get_type() != expected { + return Err(anyhow!( + "PECOS helper `{public}` is declared as `{}`, but the pecos-qis-ffi ABI is \ + `{}`. Declare it with the exported signature.", + func.get_type(), + expected, + )); + } + + match module.get_function(public) { + // The public ABI symbol is already owned by another declaration. Both passed + // the ABI check above, so they are identical: fold this duplicate into it so + // the module keeps a single unsuffixed public symbol. + Some(canonical) if canonical != func => { + func.replace_all_uses_with(canonical); + // SAFETY: all uses were just redirected to `canonical`, so this + // declaration is now unreferenced and safe to remove. + unsafe { func.delete() }; + } + // Already named with the public symbol (idempotent). + Some(_) => {} + // First occurrence: claim the public name. + None => func.as_global_value().set_name(public), + } + } + Ok(()) +} + /// Compile HUGR bytes to LLVM IR string /// /// This is the main entry point for the compiler. @@ -420,7 +592,8 @@ pub fn compile_hugr_bytes_to_string_with_options( let module = compile(args, &context, &mut hugr) .map_err(|e| PecosError::Generic(format!("Compilation failed: {e}")))?; - // Get the module string + // Get the module string (PECOS helper symbols are already normalized to their + // public names in `compile`). let mut llvm_str = module.to_string(); // Workaround: Manually add the EntryPoint attribute if it's missing @@ -494,7 +667,70 @@ pub fn compile_hugr_bytes_to_bitcode_with_options( let module = compile(args, &context, &mut hugr) .map_err(|e| PecosError::Generic(format!("Compilation failed: {e}")))?; - // Write to memory buffer and get bitcode + // Write to memory buffer and get bitcode. `as_slice()` includes LLVM's + // trailing C-string NUL, which is not part of the bitcode stream. let buffer = module.write_bitcode_to_memory(); - Ok(buffer.as_slice().to_vec()) + let bitcode = buffer.as_slice(); + Ok(bitcode[..bitcode.len().saturating_sub(1)].to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pecos_helper_public_name_matches_private_helper_symbols() { + // Bare, module-level declaration. + assert_eq!( + pecos_helper_public_name( + "__hugr__.pecos_qis_trace_metadata_hugr.16", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_trace_metadata_hugr"), + ); + // Module-qualified (e.g. `__main__`) declaration. + assert_eq!( + pecos_helper_public_name( + "__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_trace_metadata_qubit_hugr"), + ); + // Function-local declaration: the raw module symbol carries a `` + // segment (this is the case guppylang 0.21.11 produces; in LLVM text it is + // additionally quoted, but the module symbol seen here is unquoted). + assert_eq!( + pecos_helper_public_name( + "__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_runtime_barrier_qubits2_hugr"), + ); + assert_eq!( + pecos_helper_public_name( + "__hugr__.m.f..pecos_qis_runtime_barrier_qubit_hugr.7", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_runtime_barrier_qubit_hugr"), + ); + } + + #[test] + fn pecos_helper_public_name_ignores_non_helpers() { + // A non-PECOS helper under the private prefix is left alone. + assert_eq!( + pecos_helper_public_name("__hugr__.m.f..other_helper.9", PECOS_HELPER_SYMBOLS), + None, + ); + // A symbol that is not under the private prefix is not a candidate. + assert_eq!( + pecos_helper_public_name("pecos_qis_trace_metadata_hugr", PECOS_HELPER_SYMBOLS), + None, + ); + // Too few components to carry a `.` tail. + assert_eq!( + pecos_helper_public_name("__hugr__.foo", PECOS_HELPER_SYMBOLS), + None, + ); + } } diff --git a/crates/pecos-hugr-qis/src/lib.rs b/crates/pecos-hugr-qis/src/lib.rs index c051153b4..d1e836c76 100644 --- a/crates/pecos-hugr-qis/src/lib.rs +++ b/crates/pecos-hugr-qis/src/lib.rs @@ -80,6 +80,9 @@ pub use result_tags::{extract_result_tag_measurements, measurement_op_count}; // Re-export inkwell's OptimizationLevel for convenience pub use tket::hugr::llvm::inkwell::OptimizationLevel; +// Re-export the QSystem platform selector used by `CompileArgs`/`HugrCompilerConfig` +pub use tket_qsystem::QSystemPlatform; + // Extension registry used throughout the crate // Convenience functions @@ -101,6 +104,8 @@ pub struct HugrCompilerConfig { pub target_triple: Option, /// Optimization level (defaults to O2) pub opt_level: Option, + /// Target `QSystem` platform (defaults to [`QSystemPlatform::Helios`] when `None`) + pub platform: Option, } impl HugrCompilerConfig { @@ -112,6 +117,7 @@ impl HugrCompilerConfig { save_hugr: self.save_hugr.clone(), target_triple: self.target_triple.clone(), opt_level: self.opt_level.unwrap_or(OptimizationLevel::Default), + platform: self.platform.unwrap_or(QSystemPlatform::Helios), } } } diff --git a/crates/pecos-hugr-qis/src/prelude.rs b/crates/pecos-hugr-qis/src/prelude.rs index 53367a625..fe022dfbf 100644 --- a/crates/pecos-hugr-qis/src/prelude.rs +++ b/crates/pecos-hugr-qis/src/prelude.rs @@ -22,7 +22,9 @@ pub use crate::{ }; // Re-export types -pub use crate::{CompileArgs, HugrCompiler, HugrCompilerConfig, OptimizationLevel}; +pub use crate::{ + CompileArgs, HugrCompiler, HugrCompilerConfig, OptimizationLevel, QSystemPlatform, +}; // Re-export helper functions pub use crate::{ diff --git a/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_collision.hugr b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_collision.hugr new file mode 100644 index 000000000..5c5ae79fb Binary files /dev/null and b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_collision.hugr differ diff --git a/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_probe.hugr b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_probe.hugr new file mode 100644 index 000000000..8dc9b8b90 Binary files /dev/null and b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_probe.hugr differ diff --git a/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_single_wrong_abi.hugr b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_single_wrong_abi.hugr new file mode 100644 index 000000000..7d6366369 Binary files /dev/null and b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_single_wrong_abi.hugr differ diff --git a/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_wrong_signature.hugr b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_wrong_signature.hugr new file mode 100644 index 000000000..5241b9384 Binary files /dev/null and b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_wrong_signature.hugr differ diff --git a/crates/pecos-hugr-qis/tests/test_compilation.rs b/crates/pecos-hugr-qis/tests/test_compilation.rs index 058858fee..bac97b269 100644 --- a/crates/pecos-hugr-qis/tests/test_compilation.rs +++ b/crates/pecos-hugr-qis/tests/test_compilation.rs @@ -135,3 +135,129 @@ fn test_optimization_levels() { ); } } + +#[test] +fn function_local_helper_lowers_to_public_symbol_in_text_and_bitcode() { + // A Guppy program (captured from `barrier_pair_probe`) that declares and calls a + // function-local PECOS helper. guppylang qualifies the helper with a `` + // segment under the private `__hugr__.*` namespace; BOTH the text IR and the + // bitcode must expose the public `pecos_qis_runtime_barrier_qubits2_hugr` ABI + // symbol that pecos-qis-ffi exports, not the private name. (Regression guard for + // the bitcode path, which a text-only normalization would miss.) + let hugr = include_bytes!("fixtures/szz_barrier_probe.hugr"); + + let text = compile_hugr_bytes_to_string(hugr).expect("text compilation should succeed"); + assert!( + text.contains("@pecos_qis_runtime_barrier_qubits2_hugr("), + "text IR is missing the public helper symbol" + ); + assert!( + !text.contains(".pecos_qis_runtime_barrier_qubits2_hugr"), + "text IR still carries the private helper symbol" + ); + + let bitcode = compile_hugr_bytes_to_bitcode(hugr).expect("bitcode compilation should succeed"); + // LLVM stores symbol names contiguously in the bitcode string table, so a byte + // scan is reliable. The private form embeds the helper under `.`; the + // entry function `barrier_pair_probe` is also function-local but does not match + // this needle, so it is a clean discriminator. + let private = b".pecos_qis_runtime_barrier_qubits2_hugr"; + let public = b"pecos_qis_runtime_barrier_qubits2_hugr"; + assert!( + !bitcode.windows(private.len()).any(|w| w == private), + "bitcode still carries the private helper symbol" + ); + assert!( + bitcode.windows(public.len()).any(|w| w == public), + "bitcode is missing the public helper symbol" + ); +} + +#[test] +fn duplicate_helper_declarations_merge_to_one_public_symbol() { + // A HUGR with two declarations of the same helper -- one module-level and one + // function-local wrapper -- both normalize to + // `pecos_qis_runtime_barrier_qubits2_hugr`. A blind rename would let LLVM uniquify + // the second declaration to `...hugr.1`, which pecos-qis-ffi does not export; the + // merge must collapse them into one unsuffixed public symbol. (Regression guard + // for the module-level rename collision.) + let hugr = include_bytes!("fixtures/szz_barrier_collision.hugr"); + + let text = compile_hugr_bytes_to_string(hugr).expect("text compilation should succeed"); + assert!( + text.contains("@pecos_qis_runtime_barrier_qubits2_hugr("), + "text IR is missing the public helper symbol" + ); + // The helper name is never followed by a dot: no `.1` collision suffix and no + // private `__hugr__...pecos_qis_runtime_barrier_qubits2_hugr.` residue. + assert!( + !text.contains("pecos_qis_runtime_barrier_qubits2_hugr."), + "text IR has a suffixed or private helper symbol" + ); + + let bitcode = compile_hugr_bytes_to_bitcode(hugr).expect("bitcode compilation should succeed"); + let suffixed_or_private = b"pecos_qis_runtime_barrier_qubits2_hugr."; + let public = b"pecos_qis_runtime_barrier_qubits2_hugr"; + assert!( + !bitcode + .windows(suffixed_or_private.len()) + .any(|w| w == suffixed_or_private), + "bitcode has a suffixed or private helper symbol" + ); + assert!( + bitcode.windows(public.len()).any(|w| w == public), + "bitcode is missing the public helper symbol" + ); +} + +#[test] +fn conflicting_helper_signatures_fail_loud() { + // Two declarations that normalize to `pecos_qis_runtime_barrier_qubit_hugr`, one + // with the wrong signature (`i64 -> { i64, i64 }` vs the ABI `i64 -> i64`). The + // wrong declaration must be rejected against the fixed pecos-qis-ffi ABI -- LLVM 21 + // opaque pointers + `module.verify()` do not catch a call that disagrees with the + // export -- so compilation must fail loud instead of shipping ABI-broken IR. + let hugr = include_bytes!("fixtures/szz_barrier_wrong_signature.hugr"); + + let text = compile_hugr_bytes_to_string(hugr); + assert!( + text.is_err(), + "text compilation should fail on a wrong-ABI helper declaration" + ); + let msg = text.unwrap_err().to_string(); + assert!( + msg.contains("pecos-qis-ffi ABI") && msg.contains("pecos_qis_runtime_barrier_qubit_hugr"), + "unexpected error message: {msg}" + ); + + assert!( + compile_hugr_bytes_to_bitcode(hugr).is_err(), + "bitcode compilation should fail on a wrong-ABI helper declaration" + ); +} + +#[test] +fn single_wrong_helper_signature_fails_loud() { + // A SINGLE declaration of a recognized helper with a self-consistent but wrong ABI + // (`pecos_qis_runtime_barrier_qubits2_hugr` declared `i64 -> i64` instead of the + // exported `(i64, i64) -> { i64, i64 }`). There is no sibling to compare against, + // so this is caught only by validating the lone declaration against the fixed + // pecos-qis-ffi ABI. Both text IR and bitcode compilation must fail loud. + let hugr = include_bytes!("fixtures/szz_barrier_single_wrong_abi.hugr"); + + let text = compile_hugr_bytes_to_string(hugr); + assert!( + text.is_err(), + "text compilation should fail on a lone wrong-ABI helper declaration" + ); + let msg = text.unwrap_err().to_string(); + assert!( + msg.contains("pecos-qis-ffi ABI") && msg.contains("pecos_qis_runtime_barrier_qubits2_hugr"), + "unexpected error message: {msg}" + ); + + assert!( + compile_hugr_bytes_to_bitcode(hugr).is_err(), + "bitcode compilation should fail on a lone wrong-ABI helper declaration" + ); +} diff --git a/crates/pecos-hugr/Cargo.toml b/crates/pecos-hugr/Cargo.toml index 117ea07e8..aabc50ba4 100644 --- a/crates/pecos-hugr/Cargo.toml +++ b/crates/pecos-hugr/Cargo.toml @@ -29,9 +29,6 @@ serde_json.workspace = true # HUGR support tket.workspace = true tket-qsystem.workspace = true -# Pin hugr-core to 0.25.6 to avoid strum 0.27/0.28 conflict with tket 0.17. -# See workspace Cargo.toml for details. Remove once tket supports strum 0.28+. -hugr-core.workspace = true # Workspace dependencies pecos-core.workspace = true @@ -40,6 +37,7 @@ pecos-quantum = { workspace = true, features = ["hugr"] } pecos-wasm = { workspace = true, optional = true } [dev-dependencies] +env_logger.workspace = true tempfile.workspace = true # For creating test HUGRs from DagCircuit pecos-quantum = { workspace = true, features = ["hugr"] } diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 49785da0d..6dc547057 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -22,11 +22,14 @@ //! - [`analysis`]: HUGR static analysis and extraction functions //! - [`control_flow`]: Control flow handling (`TailLoop`, Conditional, CFG, Call) +mod activation; pub(crate) mod analysis; mod control_flow; mod handlers; +use handlers::{ClassicalOutcome, HandlerOutcome}; mod propagation; pub(crate) mod types; +mod work_queue; use std::any::Any; use std::collections::{BTreeMap, BTreeSet, VecDeque}; @@ -48,17 +51,17 @@ pub use types::{CapturedResult, ClassicalValue, FutureId, ResultValue, RngContex // Use internal types from submodules use types::{ - ActiveCallInfo, ActiveCaseInfo, ActiveCfgInfo, ActiveTailLoopInfo, CfgInfo, ClassicalOp, - ConditionalInfo, ExtensionState, FuncDefnInfo, MeasurementState, QuantumOp, TailLoopInfo, - WireState, + ActiveCallInfo, ActiveCaseInfo, ActiveCfgInfo, ActiveScanInfo, ActiveTailLoopInfo, CfgInfo, + ClassicalOp, ConditionalInfo, ExtensionState, FuncDefnInfo, MeasurementState, QuantumOp, + TailLoopInfo, WireState, }; // Use analysis functions from submodule use analysis::{ - all_predecessors_ready, collect_descendants, extract_call_targets, extract_cfgs, - extract_classical_ops, extract_conditionals, extract_func_defns, extract_quantum_ops, - extract_tailloops, find_nodes_inside_cases, find_nodes_inside_cfg_blocks, - find_nodes_inside_func_defns, find_nodes_inside_tailloops, + collect_descendants, extract_call_targets, extract_cfgs, extract_classical_ops, + extract_conditionals, extract_func_defns, extract_quantum_ops, extract_tailloops, + find_nodes_inside_cases, find_nodes_inside_cfg_blocks, find_nodes_inside_func_defns, + find_nodes_inside_tailloops, }; /// A HUGR interpreter engine that directly executes HUGR programs. /// @@ -74,7 +77,10 @@ use analysis::{ /// 3. Operations from the selected branch are processed pub struct HugrEngine { /// The HUGR program being executed. - pub(crate) hugr: Option, + /// The loaded program, shared behind an Arc: the main loop and every + /// resolution wave take a handle per round, and a deep graph clone + /// there cost O(program) per measurement round. + pub(crate) hugr: Option>, /// Extracted quantum operations indexed by node. pub(crate) quantum_ops: BTreeMap, @@ -83,11 +89,27 @@ pub struct HugrEngine { pub(crate) classical_ops: BTreeMap, /// Work queue for topological traversal. - pub(crate) work_queue: VecDeque, + pub(crate) work_queue: work_queue::WorkQueue, /// Set of processed nodes. pub(crate) processed: BTreeSet, + /// In-flight higher-order array scans, keyed by scan node. + pub(crate) active_scans: BTreeMap, + + /// The entrypoint's classical return values, captured when its CFG + /// completes: entry i is output port i's value, or None if it never + /// materialized (positional, so a missing port cannot relabel the + /// rest). Pure-classical programs (no measurements, no `result()` + /// calls) surface these as their shot results. + pub(crate) return_values: Vec>, + + /// Container regions the engine actually activated this shot + /// (`DataflowBlocks`, selected Cases, `TailLoop` bodies), with a label for + /// diagnostics. Persistent across the shot (unlike the active_* maps), + /// so completion can audit that every child of an executed region ran. + pub(crate) executed_containers: BTreeMap, + /// Reusable message builder for generating commands. pub(crate) message_builder: ByteMessageBuilder, @@ -106,12 +128,16 @@ pub struct HugrEngine { pub(crate) conditionals: BTreeMap, /// Pending conditionals waiting for measurement results. - /// Maps the Conditional node to the qubit ID whose measurement determines the branch. - pub(crate) pending_conditionals: BTreeMap, + /// Conditionals whose control value is not yet resolvable (waiting on + /// a measurement); retried when results arrive. + pub(crate) pending_conditionals: BTreeSet, - /// Pending bool.read nodes waiting for measurement results. - /// These are re-added to the work queue when measurement results arrive. - pub(crate) pending_bool_reads: BTreeSet, + /// The starved-node parking lot: every op that DEFERRED (missing or + /// unconvertible inputs) -- classical ops, bool reads, extension ops, + /// `LoadConstants`, parked scans. Re-queued by `retry_deferred_nodes` on + /// completions and measurement rounds; anything still here at + /// completion time surfaces in the stall report. + pub(crate) deferred_nodes: BTreeSet, /// Set of nodes that are inside Case nodes (children of Conditionals). /// These should not be processed until their parent Conditional is expanded. @@ -137,7 +163,16 @@ pub struct HugrEngine { /// Pending block propagations that need re-propagation after measurement results. /// Stores (`cfg_node`, `from_block`, `to_block`) tuples. - pub(crate) pending_measurement_propagations: Vec<(Node, Node, Node)>, + pub(crate) pending_measurement_propagations: Vec<(Node, Node, Node, u64)>, + + /// Monotone id for each `transition_to_cfg_successor` invocation (one + /// synchronous cascade of block hops). Recorded on each replay edge so + /// (a) a block revisited WITHIN one cascade does not purge the older + /// hop into it -- the chain a late measurement value must walk -- while + /// a re-entry in a LATER cascade (next loop iteration) does, and (b) + /// replay uses only each CFG's latest cascade, never re-filling ports + /// from a superseded iteration's sources. + pub(crate) cfg_transition_cascade: u64, // === Call/FuncDefn Support === /// `FuncDefn` nodes extracted from the HUGR. @@ -155,7 +190,7 @@ pub struct HugrEngine { /// Pending Calls waiting for a `FuncDefn` to be free. /// Maps `FuncDefn` node -> queue of Call nodes waiting. - pub(crate) pending_func_calls: BTreeMap>, + pub(crate) pending_func_calls: BTreeMap>, // === TailLoop Support === /// `TailLoop` nodes extracted from the HUGR. @@ -169,6 +204,12 @@ pub struct HugrEngine { /// Pending `TailLoops` waiting for Sum value (measurement result) to determine continue/break. pub(crate) pending_tailloop_control: BTreeSet, + /// A fatal execution fault raised from deep (non-Result) code paths -- + /// e.g. an executed `prelude.panic`, an out-of-range branch tag, or a + /// loop-iteration ceiling. Checked by the main processing loop, which + /// converts it into an error instead of continuing on corrupt control + /// flow. + pub(crate) execution_error: Option, // === Result Capture === /// Captured results from tket.result operations. @@ -311,7 +352,6 @@ impl HugrEngine { // Extract TailLoop control flow structures self.tailloops = extract_tailloops(&hugr); debug!("Extracted {} TailLoop nodes", self.tailloops.len()); - eprintln!("[DEBUG] Extracted {} TailLoop nodes", self.tailloops.len()); // Track nodes inside TailLoop bodies (should not be processed until loop is active) self.nodes_inside_tailloops = find_nodes_inside_tailloops(&hugr, &self.tailloops); @@ -323,8 +363,8 @@ impl HugrEngine { // Extract quantum operations (but we'll skip case/CFG-internal ones in work queue) self.quantum_ops = extract_quantum_ops(&hugr); debug!("Extracted {} quantum operations", self.quantum_ops.len()); - eprintln!( - "[DEBUG] Extracted {} quantum ops, {} cfgs, {} func_defns, {} call_targets", + debug!( + "Extracted {} quantum ops, {} cfgs, {} func_defns, {} call_targets", self.quantum_ops.len(), self.cfgs.len(), self.func_defns.len(), @@ -338,7 +378,7 @@ impl HugrEngine { self.classical_ops.len() ); - self.hugr = Some(hugr); + self.hugr = Some(std::sync::Arc::new(hugr)); self.reset_state(); } @@ -358,13 +398,14 @@ impl HugrEngine { // Clear Conditional control flow state self.pending_conditionals.clear(); - self.pending_bool_reads.clear(); + self.deferred_nodes.clear(); self.active_cases.clear(); // Clear CFG control flow state self.active_cfgs.clear(); self.pending_cfg_branches.clear(); self.pending_measurement_propagations.clear(); + self.cfg_transition_cascade = 0; // Clear Call/FuncDefn control flow state self.active_calls.clear(); @@ -373,6 +414,10 @@ impl HugrEngine { // Clear TailLoop control flow state self.active_tailloops.clear(); self.pending_tailloop_control.clear(); + self.execution_error = None; + self.active_scans.clear(); + self.return_values.clear(); + self.executed_containers.clear(); // Clear result capture state self.captured_results.clear(); @@ -380,6 +425,7 @@ impl HugrEngine { // Re-initialize nodes_inside_* from their respective control structures // (in case we need to re-process after a reset) if let Some(hugr) = &self.hugr { + self.nodes_inside_cases = find_nodes_inside_cases(hugr, &self.conditionals); self.nodes_inside_cfg_blocks = find_nodes_inside_cfg_blocks(hugr, &self.cfgs); self.nodes_inside_func_defns = find_nodes_inside_func_defns(hugr, &self.func_defns, &self.call_targets); @@ -409,15 +455,8 @@ impl HugrEngine { // (but skip nodes inside cases or CFG blocks) for node in self.quantum_ops.keys() { if !should_skip(node) - && !self.work_queue.contains(node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + && !self.work_queue.contains(*node) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -427,15 +466,8 @@ impl HugrEngine { // (but skip classical ops inside cases, CFG blocks, etc.) for node in self.classical_ops.keys() { if !should_skip(node) - && !self.work_queue.contains(node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + && !self.work_queue.contains(*node) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -445,15 +477,8 @@ impl HugrEngine { // (but skip Conditionals inside FuncDefn bodies or CFG blocks) for node in self.conditionals.keys() { if !should_skip(node) - && !self.work_queue.contains(node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + && !self.work_queue.contains(*node) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -463,15 +488,8 @@ impl HugrEngine { // (but skip CFGs inside FuncDefn bodies - they should only be activated when called) for node in self.cfgs.keys() { if !should_skip(node) - && !self.work_queue.contains(node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + && !self.work_queue.contains(*node) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -481,15 +499,8 @@ impl HugrEngine { // (but skip Calls inside FuncDefn bodies or CFG blocks) for node in self.call_targets.keys() { if !should_skip(node) - && !self.work_queue.contains(node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + && !self.work_queue.contains(*node) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -501,7 +512,7 @@ impl HugrEngine { let op = hugr.get_optype(node); if matches!(op, OpType::LoadConstant(_)) && !should_skip(&node) - && !self.work_queue.contains(&node) + && !self.work_queue.contains(node) { self.work_queue.push_back(node); } @@ -511,15 +522,8 @@ impl HugrEngine { // (but skip TailLoops inside FuncDefn bodies, CFG blocks, etc.) for node in self.tailloops.keys() { if !should_skip(node) - && !self.work_queue.contains(node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + && !self.work_queue.contains(*node) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -534,6 +538,9 @@ impl HugrEngine { /// Try to resolve pending `TailLoop` control values after measurement results are available. fn try_resolve_pending_tailloops(&mut self) { + if self.pending_tailloop_control.is_empty() { + return; + } let hugr = match &self.hugr { Some(h) => h.clone(), None => return, @@ -544,9 +551,20 @@ impl HugrEngine { self.pending_tailloop_control.len() ); - // Collect TailLoops that can now be resolved + // Collect TailLoops that can now be resolved. A loop whose body is + // still mid-iteration must NOT resolve here: a stale or early + // control value would re-activate (or complete) the loop while + // in-flight body ops still run, silently corrupting the iteration. + // Only body completion legitimately re-arms resolution. let mut to_resolve = Vec::new(); for &tailloop_node in &self.pending_tailloop_control { + if self + .active_tailloops + .get(&tailloop_node) + .is_some_and(|info| info.body_active) + { + continue; + } if let Some(tag) = self.try_resolve_tailloop_control(&hugr, tailloop_node) { to_resolve.push((tailloop_node, tag)); } @@ -556,7 +574,12 @@ impl HugrEngine { for (tailloop_node, tag) in to_resolve { self.pending_tailloop_control.remove(&tailloop_node); - if tag == 0 { + if tag > 1 { + // Two variants only (0=continue, 1=break); see the loop arm. + self.execution_error = Some(format!( + "TailLoop {tailloop_node:?}: control tag {tag} out of range" + )); + } else if tag == 0 { // CONTINUE_TAG - start next iteration debug!("Pending TailLoop {tailloop_node:?}: CONTINUE, starting next iteration"); self.continue_tailloop_iteration(&hugr, tailloop_node); @@ -571,14 +594,14 @@ impl HugrEngine { /// Re-queue pending bool.read nodes that were waiting for measurement results. /// When a measurement result arrives, the classical value is stored and we need to /// retry any bool.read nodes that were deferred because their input wasn't ready. - fn retry_pending_bool_reads(&mut self) { + fn retry_deferred_nodes(&mut self) { // Move pending bool.reads to work queue so they can be retried - let pending: Vec<_> = std::mem::take(&mut self.pending_bool_reads) + let pending: Vec<_> = std::mem::take(&mut self.deferred_nodes) .into_iter() .collect(); for node in pending { - if !self.processed.contains(&node) && !self.work_queue.contains(&node) { + if !self.processed.contains(&node) && !self.work_queue.contains(node) { self.work_queue.push_back(node); } } @@ -609,6 +632,13 @@ impl HugrEngine { /// - `Ok(None)` - No operations to process (empty or complete) #[allow(clippy::too_many_lines, clippy::unnecessary_wraps)] fn process_hugr_impl(&mut self) -> Result, PecosError> { + // A fault raised by a completion cascade (e.g. during measurement + // handling) must surface even when the queue is empty -- check + // BEFORE the early returns below, or the message is discarded and + // at best re-reported as a generic stall. + if let Some(fault) = self.execution_error.take() { + return Err(PecosError::Generic(fault)); + } self.message_builder.reset(); let _ = self.message_builder.for_quantum_operations(); @@ -618,27 +648,36 @@ impl HugrEngine { }; if self.work_queue.is_empty() && self.quantum_ops.is_empty() { + // "Nothing to do" is only a completion claim if nothing is + // stranded -- a purely classical program that starved mid-run + // also lands here, and it must report as a stall. + self.ensure_no_stalled_execution()?; debug!("Empty HUGR, no commands to generate"); - eprintln!("[DEBUG] Empty HUGR, no commands to generate"); return Ok(None); } if self.work_queue.is_empty() { + // Same completion claim as the post-drain return below: an + // already-empty queue with active control flow or starved nodes + // is a stall, not a finished program. + self.ensure_no_stalled_execution()?; debug!("Work queue empty, processing complete"); - eprintln!("[DEBUG] Work queue empty, processing complete"); return Ok(None); } - eprintln!("[DEBUG] Work queue has {} items", self.work_queue.len()); + debug!("Work queue has {} items", self.work_queue.len()); let mut operation_count = 0; let mut hit_measurement = false; while let Some(current_node) = self.work_queue.pop_front() { + if let Some(fault) = self.execution_error.take() { + return Err(PecosError::Generic(fault)); + } if self.processed.contains(¤t_node) { continue; } let node_op = hugr.get_optype(current_node); - eprintln!("[DEBUG] Processing node {current_node:?}: {node_op:?}"); + debug!("Processing node {current_node:?}: {node_op:?}"); // Check batch size if operation_count >= Self::MAX_BATCH_SIZE { @@ -656,34 +695,45 @@ impl HugrEngine { // Expand the selected branch and add its entry nodes to the queue let entry_nodes = self.expand_conditional(&hugr, current_node, branch_index); for entry_node in entry_nodes { - if !self.work_queue.contains(&entry_node) { + if !self.work_queue.contains(entry_node) { self.work_queue.push_back(entry_node); } } debug!("Conditional {current_node:?} expanded, branch {branch_index} selected"); - - // Check if this Conditional completion allows a CFG block to complete - self.check_cfg_block_completion(&hugr, current_node); + // Completion hooks for a zero-op case (block completion, + // consumer wake-up) run inside expand_conditional; a + // non-empty case completes later via check_case_completion. } else { // Can't resolve yet - likely waiting for measurement result // Add to pending conditionals and continue debug!("Conditional {current_node:?} cannot be resolved yet, deferring"); // We'll re-add this after measurement results come in // For now, mark as pending and don't add back to queue - self.pending_conditionals - .insert(current_node, QubitId::from(0)); // placeholder + self.pending_conditionals.insert(current_node); } continue; } // --- Control Flow: CFG --- if let Some(cfg_info) = self.cfgs.get(¤t_node).cloned() { + // A CFG re-queued while it is still executing must NOT + // restart: re-registering resets current_block/transitions + // mid-flight and silently corrupts the walk. (Legitimate + // re-execution -- a second Call to the same function -- + // only happens after complete_cfg_execution removed the + // active entry.) + if self.active_cfgs.contains_key(¤t_node) { + debug!("CFG {current_node:?} re-queued while active, ignoring"); + continue; + } debug!("Starting CFG {current_node:?} execution"); debug!("[TRACE] Starting CFG {current_node:?}"); - eprintln!( - "[DEBUG] Starting CFG {current_node:?}, entry_block={:?}", - cfg_info.entry_block - ); + + // A fresh walk must not replay the previous invocation's + // measurement-propagation edges (completion purges them; + // this covers a walk that never completed). + self.pending_measurement_propagations + .retain(|(cfg, _, _, _)| *cfg != current_node); // Start CFG execution by activating the entry block's operations let entry_block = cfg_info.entry_block; @@ -695,176 +745,87 @@ impl HugrEngine { cfg_node: current_node, current_block: entry_block, completed_blocks: BTreeSet::new(), + transitions: 0, }, ); // Propagate CFG inputs to entry block's Input node self.propagate_cfg_inputs_to_entry_block(&hugr, current_node, entry_block); - // Remove entry block's quantum ops from nodes_inside_cfg_blocks - // and add ops whose predecessors are ready to the work queue + self.executed_containers + .insert(entry_block, "DataflowBlock"); + // First activation of the entry block via the shared + // mechanism (no resets -- nothing has executed yet). + // Ops inside TailLoops leave the block gate but queue + // only when their loop expands; TailLoop nodes + // themselves queue unconditionally (they handle input + // propagation during expansion). + let mut act = activation::ContainerActivation::new(); + let submit = + |act: &mut activation::ContainerActivation, + node: Node, + policy: activation::QueuePolicy| { + if self.nodes_inside_tailloops.contains(&node) { + act.ungate_block_only(node); + } else { + act.queue(node, policy); + } + }; for &op_node in &block_info.quantum_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip ops inside TailLoops - they'll be added when the loop expands - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - let preds_ready = all_predecessors_ready( - &hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ); - if !self.work_queue.contains(&op_node) - && !self.processed.contains(&op_node) - && preds_ready - { - self.work_queue.push_back(op_node); - } + submit(&mut act, op_node, activation::QueuePolicy::IfReady); } - - // Also activate Call nodes in the entry block for child in hugr.children(entry_block) { - let op = hugr.get_optype(child); - if matches!(op, OpType::Call(_)) { - self.nodes_inside_cfg_blocks.remove(&child); - // Skip Call nodes inside TailLoops - they'll be added when the loop expands - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - if !self.work_queue.contains(&child) - && !self.processed.contains(&child) - && all_predecessors_ready( - &hugr, - child, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(child); - } + if matches!(hugr.get_optype(child), OpType::Call(_)) { + submit(&mut act, child, activation::QueuePolicy::IfReady); } } - - // Also activate Conditional nodes in the entry block for &cond_node in &block_info.conditional_nodes { - self.nodes_inside_cfg_blocks.remove(&cond_node); - // Skip Conditional nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&cond_node) { - continue; - } - if !self.work_queue.contains(&cond_node) - && !self.processed.contains(&cond_node) - { - self.work_queue.push_back(cond_node); - } + submit(&mut act, cond_node, activation::QueuePolicy::Always); } - - // Also activate bool ops in the entry block for &op_node in &block_info.bool_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip bool ops inside TailLoops - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - if !self.work_queue.contains(&op_node) && !self.processed.contains(&op_node) - { - self.work_queue.push_back(op_node); - } + submit(&mut act, op_node, activation::QueuePolicy::Always); } - - // Also activate LoadConstant and classical ops in the entry block for child in hugr.children(entry_block) { - let op = hugr.get_optype(child); - if matches!(op, OpType::LoadConstant(_)) { - self.nodes_inside_cfg_blocks.remove(&child); - // Skip nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - if !self.work_queue.contains(&child) && !self.processed.contains(&child) - { - self.work_queue.push_back(child); - } + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + submit(&mut act, child, activation::QueuePolicy::Always); } - // Check for classical ops (extension ops in arithmetic.int, etc.) if self.classical_ops.contains_key(&child) { - self.nodes_inside_cfg_blocks.remove(&child); - // Skip nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - // Classical ops need their inputs ready - if !self.work_queue.contains(&child) - && !self.processed.contains(&child) - && all_predecessors_ready( - &hugr, - child, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(child); - } + submit(&mut act, child, activation::QueuePolicy::IfReady); } } - - // Also activate extension ops (tket.rotation, tket.result, etc.) - // Use block_info.extension_ops which is already filtered to exclude - // quantum_ops, bool_ops, and classical_ops (those are handled above). for &op_node in &block_info.extension_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip extension ops inside TailLoops - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - if !self.work_queue.contains(&op_node) - && !self.processed.contains(&op_node) - && all_predecessors_ready( - &hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(op_node); - } + submit(&mut act, op_node, activation::QueuePolicy::IfReady); } - - // Also activate TailLoop nodes in the entry block - // NOTE: Don't check preds_ready for TailLoops - they handle input - // propagation separately during expansion. for &tl_node in &block_info.tailloop_nodes { - self.nodes_inside_cfg_blocks.remove(&tl_node); - if !self.work_queue.contains(&tl_node) && !self.processed.contains(&tl_node) - { - self.work_queue.push_back(tl_node); - } + act.queue(tl_node, activation::QueuePolicy::Always); } + self.run_activation(&hugr, &act); let num_ops = block_info.quantum_ops.len(); let num_calls = block_info.call_nodes.len(); let num_conditionals = block_info.conditional_nodes.len(); let num_bool_ops = block_info.bool_ops.len(); let num_tailloops = block_info.tailloop_nodes.len(); + let num_classical = block_info.classical_ops.len(); + let num_extension = block_info.extension_ops.len(); debug!( - "CFG {current_node:?}: activated entry block {entry_block:?} with {num_ops} ops, {num_conditionals} conditionals, {num_bool_ops} bool_ops, {num_tailloops} tailloops" + "CFG {current_node:?}: activated entry block {entry_block:?} with {num_ops} ops, {num_conditionals} conditionals, {num_bool_ops} bool_ops, {num_tailloops} tailloops, {num_classical} classical, {num_extension} extension" ); - // If entry block has no operations, immediately transition to successor + // If entry block has no operations AT ALL, immediately + // transition to the successor. Classical and extension ops + // count: transitioning before they run would propagate + // missing block outputs (e.g. a loop-bounds tuple built + // from LoadConstant + MakeTuple) and starve everything + // downstream. if num_ops == 0 && num_calls == 0 && num_conditionals == 0 && num_bool_ops == 0 && num_tailloops == 0 + && num_classical == 0 + && num_extension == 0 + && block_info.load_constants.is_empty() { debug!( "[TRACE] Entry block {:?} has 0 ops and 0 calls, successors: {:?}", @@ -910,6 +871,15 @@ impl HugrEngine { entry_block, successors[branch_idx], ); + } else { + // An out-of-range tag means a Sum/tag + // propagation bug upstream -- taking an + // arbitrary branch would mask it as a + // plausible control-flow path. + self.execution_error = Some(format!( + "CFG {current_node:?} block {entry_block:?}: branch tag {branch_idx} out of range ({} successors)", + successors.len() + )); } } else { debug!("[TRACE] Branch NOT resolved, adding to pending"); @@ -926,10 +896,21 @@ impl HugrEngine { // --- Control Flow: TailLoop --- if self.tailloops.contains_key(¤t_node) { // Check if already active - if self.active_tailloops.contains_key(¤t_node) { - // Active TailLoop - check if we can resolve control - if let Some(tag) = self.try_resolve_tailloop_control(&hugr, current_node) { - if tag == 0 { + if let Some(active_info) = self.active_tailloops.get(¤t_node) { + // A loop whose body is still mid-iteration must not + // resolve control: a stale/early value would re-activate + // or complete it over in-flight body ops. Body + // completion re-arms resolution. + if active_info.body_active { + debug!("TailLoop {current_node:?}: body mid-iteration, not resolving"); + } else if let Some(tag) = self.try_resolve_tailloop_control(&hugr, current_node) + { + if tag > 1 { + // Two variants only (0=continue, 1=break). + self.execution_error = Some(format!( + "TailLoop {current_node:?}: control tag {tag} out of range" + )); + } else if tag == 0 { // CONTINUE_TAG - start next iteration debug!("TailLoop {current_node:?}: CONTINUE, starting next iteration"); self.continue_tailloop_iteration(&hugr, current_node); @@ -944,14 +925,18 @@ impl HugrEngine { self.pending_tailloop_control.insert(current_node); } } else { - // Not active - start first iteration - debug!("TailLoop {current_node:?}: starting first iteration"); - let entry_nodes = self.expand_tailloop(&hugr, current_node); - for entry_node in entry_nodes { - if !self.work_queue.contains(&entry_node) { - self.work_queue.push_back(entry_node); - } + // Not active - start first iteration, but only once the + // loop's input producers have run: expansion propagates + // the TailLoop's input wires into the body exactly once, + // so expanding early starves the body forever. When a + // producer completes, queue_ready_successors re-queues + // this node. + if !self.all_predecessors_ready(&hugr, current_node) { + debug!("TailLoop {current_node:?}: inputs not ready, deferring expansion"); + continue; } + debug!("TailLoop {current_node:?}: starting first iteration"); + self.expand_tailloop(&hugr, current_node); } continue; } @@ -964,62 +949,100 @@ impl HugrEngine { } debug!("Processing Call {current_node:?} to FuncDefn {func_defn_node:?}"); - eprintln!( - "[DEBUG] Processing Call {current_node:?} to FuncDefn {func_defn_node:?}" - ); - // Check if there's already an active call to this FuncDefn - // If so, queue this call to wait + // Check if there's already an active call OR an in-flight + // scan folding through this FuncDefn -- both own the single + // execution frame, and activating over a scan would reset + // the scanned function's state mid-element. let func_defn_in_use = self .active_calls .values() - .any(|info| info.func_defn_node == func_defn_node); + .any(|info| info.func_defn_node == func_defn_node) + || self + .active_scans + .values() + .any(|scan| scan.func_defn_node == func_defn_node); if func_defn_in_use { + // Direct recursion (a call to F from inside F's own + // body) can never make progress: the outer invocation + // waits on this Call node while this Call waits for the + // FuncDefn to free up. Reject it immediately with a + // clear error instead of parking it (indirect recursion + // deadlocks the same way and is caught by the + // completion-time stall detection, which lists the + // parked calls). + let mut cur = hugr.get_parent(current_node); + while let Some(n) = cur { + if n == func_defn_node { + return Err(PecosError::Generic(format!( + "recursive call to FuncDefn {func_defn_node:?} at {current_node:?}: \ + recursion is not supported by the HUGR engine (no call \ + stack; each function has a single execution frame)" + ))); + } + cur = hugr.get_parent(n); + } debug!( "Call {current_node:?}: FuncDefn {func_defn_node:?} is in use, queueing" ); - self.pending_func_calls - .entry(func_defn_node) - .or_default() - .push(current_node); + let queue = self.pending_func_calls.entry(func_defn_node).or_default(); + // A parked Call re-queued by a retry wave would + // otherwise park twice. + if !queue.contains(¤t_node) { + queue.push_back(current_node); + } continue; } if let Some(func_info) = self.func_defns.get(&func_defn_node).cloned() { - // Map Call inputs to FuncDefn Input node outputs - // Call inputs come from upstream nodes + // Resolve arguments through the tracing layer (an + // argument produced inside a flattened DFG must resolve + // like any other read). A port with neither a qubit nor + // a classical value stays cleared: some argument types + // are legitimately not modeled per-wire, so the Call + // launches anyway, and a late-arriving value (e.g. a + // measurement result) is repaired fill-only by + // repropagate_active_call_inputs after each + // measurement round. + let args: Vec<(Option, Option)> = (0..func_info + .num_inputs) + .map(|in_port| { + ( + self.get_input_qubit(&hugr, current_node, in_port), + self.get_input_value(&hugr, current_node, in_port), + ) + }) + .collect(); + + // Clear every FuncDefn Input port before copying: the + // frame reset below exempts the Input node (keep_wires) + // so the fresh arguments survive -- a port must never + // keep the PREVIOUS call's argument. for in_port in 0..func_info.num_inputs { - let call_in_port = IncomingPort::from(in_port); - if let Some((src_node, src_port)) = - hugr.single_linked_output(current_node, call_in_port) - { - let src_wire = (src_node, src_port.index()); - - // Map qubits - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - let func_input_wire = (func_info.input_node, in_port); - self.wire_state - .wire_to_qubit - .insert(func_input_wire, qubit_id); - debug!( - "Call {:?}: mapped input {} qubit {:?} to FuncDefn Input {:?}", - current_node, in_port, qubit_id, func_info.input_node - ); - } - // Map classical values (including arrays) - if let Some(value) = - self.wire_state.classical_values.get(&src_wire).cloned() - { - let func_input_wire = (func_info.input_node, in_port); - self.wire_state - .classical_values - .insert(func_input_wire, value.clone()); - debug!( - "Call {:?}: mapped input {} classical value to FuncDefn Input {:?}", - current_node, in_port, func_info.input_node - ); - } + let func_input_wire = (func_info.input_node, in_port); + self.wire_state.classical_values.remove(&func_input_wire); + self.wire_state.wire_to_qubit.remove(&func_input_wire); + } + for (in_port, (qubit, value)) in args.into_iter().enumerate() { + let func_input_wire = (func_info.input_node, in_port); + if let Some(qubit_id) = qubit { + self.wire_state + .wire_to_qubit + .insert(func_input_wire, qubit_id); + debug!( + "Call {:?}: mapped input {} qubit {:?} to FuncDefn Input {:?}", + current_node, in_port, qubit_id, func_info.input_node + ); + } + if let Some(value) = value { + debug!( + "Call {:?}: mapped input {} classical value {:?} to FuncDefn Input {:?}", + current_node, in_port, value, func_info.input_node + ); + self.wire_state + .classical_values + .insert(func_input_wire, value); } } @@ -1027,53 +1050,134 @@ impl HugrEngine { if let Some(cfg_node) = func_info.cfg_node { debug!("Call {current_node:?}: starting FuncDefn CFG {cfg_node:?}"); + // Capture the Call's instantiation type args so type + // variables inside the body (e.g. a generic loop + // bound read by prelude.load_nat) can be resolved. + let type_args = if let OpType::Call(call_op) = hugr.get_optype(current_node) + { + call_op.type_args.clone() + } else { + Vec::new() + }; + // Register as active call self.active_calls.insert( current_node, ActiveCallInfo { call_node: current_node, func_defn_node, + type_args, }, ); - // Remove FuncDefn descendants from nodes_inside_func_defns - // so they can be processed now that the function is being called + // Reset the call frame via the shared mechanism: + // every descendant's processed flag AND stale wire + // values clear (critical for multiple calls to the + // same function -- with only the flags cleared, a + // Conditional inside the body can resolve from the + // PREVIOUS call's control wire and expand with stale + // case inputs before its producers re-run). The + // FuncDefn Input node keeps its wires: fresh call + // arguments were just copied onto it above. The Call + // node's OWN outputs reset too -- a consumer + // resolving against the previous invocation's + // outputs mid-call reads one-iteration-stale data. let mut descendants = BTreeSet::new(); collect_descendants(&hugr, func_defn_node, &mut descendants); + let mut act = activation::ContainerActivation::new(); for node in &descendants { self.nodes_inside_func_defns.remove(node); + act.reset(*node); + // The frame reset invalidates the PREVIOUS + // invocation's executed-container records (their + // processed flags are being cleared); this + // invocation re-records whatever it executes, so + // the completion audit covers exactly the final + // invocation of each frame. + self.executed_containers.remove(node); } - - // Mark ALL FuncDefn descendants as unprocessed so they can be re-executed - // This is critical for supporting multiple calls to the same function - for node in &descendants { - self.processed.remove(node); - } - self.processed.remove(&cfg_node); + act.keep_wires(func_info.input_node); + act.reset_processed(cfg_node); + act.reset_wires(current_node); + self.run_activation(&hugr, &act); // Add the CFG to the work queue to be processed - if !self.work_queue.contains(&cfg_node) { + if !self.work_queue.contains(cfg_node) { self.work_queue.push_front(cfg_node); } - } else { - debug!("Call {current_node:?}: FuncDefn has no CFG, passing through"); - // No CFG - just pass through qubits (identity function) - for port in 0..func_info.num_outputs { - let func_input_wire = (func_info.input_node, port); - if let Some(&qubit_id) = - self.wire_state.wire_to_qubit.get(&func_input_wire) - { - let call_output_wire = (current_node, port); - self.wire_state - .wire_to_qubit - .insert(call_output_wire, qubit_id); - } + // Don't mark Call as processed yet - wait for the + // FuncDefn's CFG to complete; the Call is completed + // in complete_func_call_if_needed. + continue; + } + + // No CFG: the body is a plain dataflow region. The + // engine only executes CFG-bodied functions, so anything + // beyond a pure Input->Output passthrough is + // unsupported -- fail loud rather than leaving the Call + // stranded outside every completion and stall check + // (which silently truncates downstream results). + debug!("Call {current_node:?}: FuncDefn has no CFG, treating as passthrough"); + // The per-port wiring checks below are vacuous for a + // zero-output function, so check the body shape first: + // any child beyond Input/Output (and inert Const + // statics) is real work this path would silently skip. + if let Some(extra) = hugr.children(func_defn_node).find(|c| { + *c != func_info.input_node + && *c != func_info.output_node + && !matches!(hugr.get_optype(*c), OpType::Const(_)) + }) { + return Err(PecosError::Generic(format!( + "Call {current_node:?} targets FuncDefn {func_defn_node:?} \ + with no CFG and a non-passthrough body (contains \ + {extra:?}); plain dataflow function bodies are not \ + supported by the HUGR engine" + ))); + } + for port in 0..func_info.num_outputs { + let out_port = IncomingPort::from(port); + let Some((src_node, src_port)) = + hugr.single_linked_output(func_info.output_node, out_port) + else { + return Err(PecosError::Generic(format!( + "Call {current_node:?} targets FuncDefn {func_defn_node:?} \ + whose Output port {port} is unwired; the caller's output \ + would silently go missing" + ))); + }; + if src_node != func_info.input_node { + return Err(PecosError::Generic(format!( + "Call {current_node:?} targets FuncDefn {func_defn_node:?} \ + with no CFG and a non-passthrough body (output {port} fed \ + by {src_node:?}); plain dataflow function bodies are not \ + supported by the HUGR engine" + ))); + } + let func_input_wire = (func_info.input_node, src_port.index()); + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&func_input_wire) + { + self.wire_state + .wire_to_qubit + .insert((current_node, port), qubit_id); + } + if let Some(value) = self + .wire_state + .classical_values + .get(&func_input_wire) + .cloned() + { + self.wire_state + .classical_values + .insert((current_node, port), value); } } + self.processed.insert(current_node); + self.check_scan_frame_completion(&hugr, current_node); + self.check_case_completion(&hugr, current_node); + self.check_cfg_block_completion(&hugr, current_node); + self.check_tailloop_body_completion(&hugr, current_node); + self.queue_ready_successors(&hugr, current_node); } - - // Don't mark Call as processed yet - wait for FuncDefn to complete - // The Call will be marked as processed in complete_func_call_if_needed continue; } @@ -1086,12 +1190,24 @@ impl HugrEngine { .insert((current_node, 0), value); debug!("LoadConstant {current_node:?}: loaded value"); } else { - debug!("LoadConstant {current_node:?}: failed to load value"); + // An unparseable constant will never parse: defer so the + // stall report names this node instead of letting its + // block complete around a missing constant-derived value. + debug!("LoadConstant {current_node:?}: failed to load value, deferring"); + self.deferred_nodes.insert(current_node); + continue; } self.processed.insert(current_node); // Retry any pending ops that might now have their inputs ready - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); + + // A Case/block may consist of just constants feeding its + // Output (e.g. a loop's continue-flag bool) -- check + // completion so outputs propagate only with values present. + self.check_scan_frame_completion(&hugr, current_node); + self.check_case_completion(&hugr, current_node); + self.check_cfg_block_completion(&hugr, current_node); self.queue_ready_successors(&hugr, current_node); continue; @@ -1105,29 +1221,43 @@ impl HugrEngine { ); // Execute the classical operation - let outputs = self.handle_classical_op(&hugr, current_node, &classical_op); - - // If outputs are empty, inputs weren't ready - defer this operation - if outputs.is_empty() && classical_op.num_outputs > 0 { - debug!("Classical op {current_node:?}: deferring - inputs not ready"); - // Clear stale output values so dependent ops see None and also defer - // This is critical for loops where old iteration values could be misread - for port in 0..classical_op.num_outputs { - self.wire_state - .classical_values - .remove(&(current_node, port)); + let outputs = match self.handle_classical_op(&hugr, current_node, &classical_op) { + ClassicalOutcome::Outputs(outputs) => outputs, + ClassicalOutcome::Defer if classical_op.num_outputs > 0 => { + debug!("Classical op {current_node:?}: deferring - inputs not ready"); + // Clear stale output values so dependent ops see None and also defer + // This is critical for loops where old iteration values could be misread + for port in 0..classical_op.num_outputs { + self.wire_state + .classical_values + .remove(&(current_node, port)); + } + // Add to pending bool reads set for retry (reusing the same mechanism) + self.deferred_nodes.insert(current_node); + continue; } - // Add to pending bool reads set for retry (reusing the same mechanism) - self.pending_bool_reads.insert(current_node); - continue; - } + // A zero-output op with nothing to store completes. + ClassicalOutcome::Defer => Vec::new(), + ClassicalOutcome::Fault(msg) => { + // Poison and mark processed; the loop-top check + // raises it before the next node fires. + self.execution_error = Some(msg); + self.processed.insert(current_node); + continue; + } + }; // Successfully resolved - remove from pending if it was there - self.pending_bool_reads.remove(¤t_node); + self.deferred_nodes.remove(¤t_node); - // Store output values + // Store output values. A QubitRef output (e.g. an + // UnpackTuple or Tag over a linear payload) is mirrored into + // the qubit-wire map so downstream gates can resolve it. for (port, value) in outputs { let wire_key = (current_node, port); + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state.wire_to_qubit.insert(wire_key, *qubit_id); + } self.wire_state.classical_values.insert(wire_key, value); } @@ -1135,11 +1265,17 @@ impl HugrEngine { self.processed.insert(current_node); // Retry any pending ops that might now have their inputs ready - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); // Check if any pending conditionals can now be resolved self.try_resolve_pending_conditionals(); + // Check if this classical op completion allows a Case to + // complete (cases may contain only classical ops, e.g. sum + // construction for an iterator's continue/break value) + self.check_scan_frame_completion(&hugr, current_node); + self.check_case_completion(&hugr, current_node); + // Check if this classical op completion allows a CFG block to complete // This is especially important for loop control (iadd for incrementing counters) self.check_cfg_block_completion(&hugr, current_node); @@ -1157,15 +1293,29 @@ impl HugrEngine { let op = hugr.get_optype(current_node); let is_extension_op = op.as_extension_op().is_some(); let ext_result = self.handle_extension_op(&hugr, current_node); - if ext_result { + if let HandlerOutcome::Fault(msg) = ext_result { + // Poison and stop this node cold: running the completion + // cascades (retries, block/case checks, successor queueing) + // on a poisoned engine does arbitrary work that can only + // mask the fault. The loop-top check raises it next. + self.execution_error = Some(msg); + self.processed.insert(current_node); + continue; + } + if !matches!(ext_result, HandlerOutcome::Defer) { self.processed.insert(current_node); // Retry any pending ops that might now have their inputs ready - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); // Check if any pending conditionals can now be resolved self.try_resolve_pending_conditionals(); + // Check if this extension op completion allows a Case to + // complete (cases may contain only classical/extension ops) + self.check_scan_frame_completion(&hugr, current_node); + self.check_case_completion(&hugr, current_node); + // Check if this extension op completion allows a CFG block to complete // This is especially important for tket.bool ops in loop control self.check_cfg_block_completion(&hugr, current_node); @@ -1177,25 +1327,51 @@ impl HugrEngine { self.queue_ready_successors(&hugr, current_node); continue; - } else if is_extension_op && !self.quantum_ops.contains_key(¤t_node) { + } else if is_extension_op + && !self.quantum_ops.contains_key(¤t_node) + && !self.processed.contains(¤t_node) + { // Extension op couldn't be processed (input not ready) - defer it // But don't defer if it's also a quantum op (e.g., MeasureFree from tket.quantum) - // - those should fall through to the quantum op handling below - self.pending_bool_reads.insert(current_node); + // - those should fall through to the quantum op handling below. + // The processed guard matters for complete-then-Defer handlers + // (a scan whose whole fold ran synchronously): re-parking a + // completed node would read as a stall at completion time. + self.deferred_nodes.insert(current_node); continue; } // Fall through to quantum op handling + // DFG containers execute by FLATTENING: their children are + // extracted into the global op maps at load (nodes_inside_* does + // not gate DFG interiors) and wire tracing crosses the boundary + // structurally, so the container node itself is a no-op -- + // marked processed explicitly rather than silently dropped. + // Classical values are NOT propagated onto a DFG's Input node + // (tracked follow-up); consumers of such values defer and the + // stall machinery keeps the gap loud. + if matches!(hugr.get_optype(current_node), OpType::DFG(_)) { + debug!("DFG container {current_node:?}: flattened, marking processed"); + self.processed.insert(current_node); + continue; + } + // --- Quantum Operations (gates, measurements) --- let Some(op) = self.quantum_ops.get(¤t_node).cloned() else { continue; }; - // Resolve qubit IDs for this operation - let qubits = self.resolve_qubits(&hugr, current_node, &op); + // Resolve qubit IDs for this operation; defer the gate if a + // qubit wire has no mapping yet (its producer has not run -- + // completion of that producer re-queues this node). + let Some(qubits) = self.resolve_qubits(&hugr, current_node, &op) else { + self.deferred_nodes.insert(current_node); + continue; + }; + self.deferred_nodes.remove(¤t_node); // Emit the gate operation - if self.emit_quantum_gate(&hugr, current_node, &op, &qubits) { + if self.emit_quantum_gate(&hugr, current_node, &op, &qubits)? { hit_measurement = true; } @@ -1203,6 +1379,7 @@ impl HugrEngine { operation_count += 1; // Check if this operation completes any active Case + self.check_scan_frame_completion(&hugr, current_node); self.check_case_completion(&hugr, current_node); // Check if this operation completes any active CFG block @@ -1220,7 +1397,26 @@ impl HugrEngine { } } - if operation_count == 0 { + if let Some(fault) = self.execution_error.take() { + return Err(PecosError::Generic(fault)); + } + // "Anything to send?" is the BUILDER's message count, not the + // dispatch loop's operation_count: extension handlers (qsystem + // Measure/MeasureReset/...) emit commands without passing through + // the quantum-op arm, and QAlloc/QFree count as operations without + // emitting anything. Judging by operation_count dropped a batch + // whose only commands were handler-emitted. + if operation_count == 0 && self.message_builder.message_count() == 0 { + // No progress at all this batch: this is the engine's + // completion claim. Any still-active control flow or starved + // deferred node at this point means execution stalled + // mid-program -- fail loud instead of returning silently + // truncated results. A batch that made progress but emitted + // nothing (e.g. lifecycle ops only) falls through and returns + // an empty message: the driver's round-trip re-enters + // handle_measurements, whose repropagation can unstick work + // that is waiting on already-recorded values. + self.ensure_no_stalled_execution()?; debug!("No operations processed"); return Ok(None); } @@ -1232,6 +1428,176 @@ impl HugrEngine { // === Helper Methods for process_hugr_impl === + /// Error if the work queue drained while control flow is still active or + /// deferred nodes are still starved. + /// + /// Called at the point where the engine is about to claim completion + /// (queue empty, no measurement pause, nothing emitted). Healthy programs + /// finish with every container completed and no pending reads; anything + /// left over here is a stall that would otherwise surface only as + /// silently missing results. + fn ensure_no_stalled_execution(&self) -> Result<(), PecosError> { + let mut stalled: Vec = Vec::new(); + if !self.active_cfgs.is_empty() { + stalled.push(format!( + "active CFGs: {:?}", + self.active_cfgs.keys().collect::>() + )); + } + if !self.active_cases.is_empty() { + stalled.push(format!( + "active Conditional cases: {:?}", + self.active_cases.keys().collect::>() + )); + } + if !self.active_calls.is_empty() { + stalled.push(format!( + "active Calls: {:?}", + self.active_calls.keys().collect::>() + )); + } + if !self.active_tailloops.is_empty() { + stalled.push(format!( + "active TailLoops: {:?}", + self.active_tailloops.keys().collect::>() + )); + } + if !self.active_scans.is_empty() { + stalled.push(format!( + "active scans: {:?}", + self.active_scans.keys().collect::>() + )); + } + if !self.deferred_nodes.is_empty() { + stalled.push(format!("starved deferred nodes: {:?}", self.deferred_nodes)); + } + if !self.pending_conditionals.is_empty() { + stalled.push(format!( + "unresolved Conditionals: {:?}", + self.pending_conditionals + )); + } + if !self.pending_cfg_branches.is_empty() { + stalled.push(format!( + "unresolved CFG branches: {:?}", + self.pending_cfg_branches.keys().collect::>() + )); + } + if !self.pending_tailloop_control.is_empty() { + stalled.push(format!( + "unresolved TailLoop controls: {:?}", + self.pending_tailloop_control + )); + } + if !self.pending_func_calls.is_empty() { + stalled.push(format!( + "parked function calls: {:?}", + self.pending_func_calls + .values() + .flatten() + .collect::>() + )); + } + // Reachability audit: the bookkeeping above only sees nodes that + // entered a queue or pending set. A node that was never QUEUED at + // all -- an op the category tracking missed inside a container the + // engine executed -- is invisible to it. Check every direct child + // of every executed region instead. + let unexecuted = self.audit_executed_containers(); + if !unexecuted.is_empty() { + let shown: Vec<&String> = unexecuted.iter().take(10).collect(); + stalled.push(format!( + "unexecuted ops in executed containers ({} total): {shown:?}", + unexecuted.len() + )); + } + if stalled.is_empty() { + Ok(()) + } else { + Err(PecosError::Generic(format!( + "HUGR execution stalled before completion; results would be silently \ + truncated ({})", + stalled.join("; ") + ))) + } + } + + /// A node is SETTLED as a dependency: processed AND no active container + /// state machine still owns it. A Conditional is marked processed at + /// EXPANSION but its outputs exist only once its selected case + /// completes; Calls/TailLoops/CFGs/scans mark processed at completion, + /// where the active check is redundant but keeps this predicate the + /// single source of truth (five sites used to hand-roll subsets of it, + /// and a sixth would have forgotten one). + pub(crate) fn node_settled(&self, node: Node) -> bool { + self.processed.contains(&node) + && !self + .active_cases + .values() + .any(|case| case.conditional_node == node) + && !self.active_tailloops.contains_key(&node) + && !self.active_calls.contains_key(&node) + && !self.active_cfgs.contains_key(&node) + && !self.active_scans.contains_key(&node) + } + + /// Whether every producer feeding `node` is settled: the gate for + /// one-shot input copiers (Calls, TailLoop/CFG activation, classical + /// and extension ops) -- firing before a producer settles copies + /// missing or stale values with no repair path. + pub(crate) fn all_predecessors_ready(&self, hugr: &Hugr, node: Node) -> bool { + for pred_node in hugr.input_neighbours(node) { + let op = hugr.get_optype(pred_node); + let gates = self.quantum_ops.contains_key(&pred_node) + || self.conditionals.contains_key(&pred_node) + || self.cfgs.contains_key(&pred_node) + || matches!( + op, + OpType::Call(_) | OpType::TailLoop(_) | OpType::LoadConstant(_) + ) + // Extension-op and executable-Tag predecessors (classical + // ops, tket.* ops, copyable sum construction) produce + // classical values; firing a consumer before they complete + // copies MISSING inputs. Linear (qubit-routing) Tags never + // execute... but every Tag classifies as executable now, so + // the classical-op check covers them. + || op.as_extension_op().is_some() + || crate::engine::analysis::classify_classical_op(op).is_some(); + if gates && !self.node_settled(pred_node) { + return false; + } + } + true + } + + /// Audit that every direct child of every executed container region + /// (activated `DataflowBlock`, selected Case, expanded `TailLoop` body) + /// was processed. Exempt kinds never execute: Input/Output boundaries, + /// static constants, and linear qubit-routing Tags (only Tags + /// classified as classical `TagSum` ops execute). + /// + /// Only the FINAL activation of a re-activated container is audited + /// (earlier iterations cleared and re-set the same flags), which is + /// exactly the activation whose flags are still live. + fn audit_executed_containers(&self) -> Vec { + let Some(hugr) = &self.hugr else { + return Vec::new(); + }; + let mut misses = Vec::new(); + for (&container, kind) in &self.executed_containers { + for child in hugr.children(container) { + let op = hugr.get_optype(child); + // Every Tag classifies as an executable TagSum (linear + // payloads become QubitRef values), so Tags are NOT exempt. + let exempt = matches!(op, OpType::Input(_) | OpType::Output(_) | OpType::Const(_)); + if !exempt && !self.processed.contains(&child) { + misses.push(format!("{child:?} ({op}) in {kind} {container:?}")); + } + } + } + misses + } + /// Emit a quantum gate operation to the message builder. /// /// This handles all gate types and their decompositions. @@ -1243,7 +1609,7 @@ impl HugrEngine { node: Node, op: &QuantumOp, qubits: &[QubitId], - ) -> bool { + ) -> Result { let mut hit_measurement = false; match op.gate_type { @@ -1293,17 +1659,17 @@ impl HugrEngine { ); } GateType::RX => { - let angle = self.resolve_rotation_angle(hugr, node, op); + let angle = self.resolve_rotation_angle(hugr, node, op)?; self.message_builder .rx(Angle64::from_radians(angle), &[qubits[0].0]); } GateType::RY => { - let angle = self.resolve_rotation_angle(hugr, node, op); + let angle = self.resolve_rotation_angle(hugr, node, op)?; self.message_builder .ry(Angle64::from_radians(angle), &[qubits[0].0]); } GateType::RZ => { - let angle = self.resolve_rotation_angle(hugr, node, op); + let angle = self.resolve_rotation_angle(hugr, node, op)?; self.message_builder .rz(Angle64::from_radians(angle), &[qubits[0].0]); } @@ -1356,7 +1722,7 @@ impl HugrEngine { ); } GateType::CRZ => { - let angle = self.resolve_rotation_angle(hugr, node, op); + let angle = self.resolve_rotation_angle(hugr, node, op)?; let half_angle = angle / 2.0; self.message_builder .rz(Angle64::from_radians(half_angle), &[qubits[1].0]); @@ -1420,11 +1786,19 @@ impl HugrEngine { } _ => { - debug!("Unsupported gate type: {:?}", op.gate_type); + // A recognized quantum op the emitter cannot lower: skipping + // it silently executes a DIFFERENT circuit (the node was + // already marked processed by the dispatcher, so nothing + // downstream would notice). + return Err(PecosError::Generic(format!( + "quantum gate {:?} at {node:?} is not supported by the \ + HUGR engine's gate emitter", + op.gate_type + ))); } } - hit_measurement + Ok(hit_measurement) } /// Resolve a rotation angle for a quantum gate. @@ -1434,10 +1808,15 @@ impl HugrEngine { /// which is needed when angles are computed dynamically (e.g., guppylang's CH /// decomposition passes rotation values through MakeTuple/UnpackTuple chains /// that can't be statically traced). - fn resolve_rotation_angle(&self, hugr: &Hugr, node: Node, op: &QuantumOp) -> f64 { + fn resolve_rotation_angle( + &self, + hugr: &Hugr, + node: Node, + op: &QuantumOp, + ) -> Result { // Try statically extracted params first (already in radians) if let Some(&angle) = op.params.first() { - return angle; + return Ok(angle); } // Fall back to runtime classical value at the angle input port. // The angle port is after all qubit inputs. @@ -1445,9 +1824,14 @@ impl HugrEngine { && let Some(halfturns) = value.as_rotation() { // Convert half-turns to radians: halfturns * pi - return halfturns * std::f64::consts::PI; + return Ok(halfturns * std::f64::consts::PI); } - 0.0 + // No silent default: a zero angle would turn the gate into a no-op and + // corrupt the simulated physics without any visible failure. + Err(PecosError::Input(format!( + "{:?} at {node:?}: rotation angle unavailable (no static extraction, no runtime value); refusing to default to 0", + op.gate_type + ))) } /// Queue ready successor nodes after processing a node. @@ -1455,6 +1839,10 @@ impl HugrEngine { /// Adds successor nodes to the work queue if they are relevant node types, /// not yet processed, not already queued, and have all predecessors ready. fn queue_ready_successors(&mut self, hugr: &Hugr, node: Node) { + debug!( + "queue_ready_successors({node:?}): neighbours={:?}", + hugr.output_neighbours(node).collect::>() + ); for succ_node in hugr.output_neighbours(node) { let is_relevant = self.quantum_ops.contains_key(&succ_node) || self.classical_ops.contains_key(&succ_node) @@ -1478,17 +1866,17 @@ impl HugrEngine { if (is_relevant || is_extension) && !inside_control_flow && !self.processed.contains(&succ_node) - && !self.work_queue.contains(&succ_node) - && all_predecessors_ready( - hugr, - succ_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + && !self.work_queue.contains(succ_node) + && self.all_predecessors_ready(hugr, succ_node) { self.work_queue.push_back(succ_node); + } else if is_relevant || is_extension { + debug!( + "queue_ready_successors({node:?}): skipped {succ_node:?} gated={inside_control_flow} processed={} queued={} ready={}", + self.processed.contains(&succ_node), + self.work_queue.contains(succ_node), + self.all_predecessors_ready(hugr, succ_node) + ); } } } @@ -1500,8 +1888,11 @@ impl Default for HugrEngine { hugr: None, quantum_ops: BTreeMap::new(), classical_ops: BTreeMap::new(), - work_queue: VecDeque::new(), + work_queue: work_queue::WorkQueue::new(), processed: BTreeSet::new(), + active_scans: BTreeMap::new(), + return_values: Vec::new(), + executed_containers: BTreeMap::new(), message_builder: ByteMessageBuilder::new(), // Grouped state wire_state: WireState::default(), @@ -1509,8 +1900,8 @@ impl Default for HugrEngine { extension_state: ExtensionState::default(), // Control flow fields (Conditional) conditionals: BTreeMap::new(), - pending_conditionals: BTreeMap::new(), - pending_bool_reads: BTreeSet::new(), + pending_conditionals: BTreeSet::new(), + deferred_nodes: BTreeSet::new(), nodes_inside_cases: BTreeSet::new(), active_cases: BTreeMap::new(), // Control flow fields (CFG) @@ -1519,6 +1910,7 @@ impl Default for HugrEngine { active_cfgs: BTreeMap::new(), pending_cfg_branches: BTreeMap::new(), pending_measurement_propagations: Vec::new(), + cfg_transition_cascade: 0, // Control flow fields (Call/FuncDefn) func_defns: BTreeMap::new(), call_targets: BTreeMap::new(), @@ -1530,6 +1922,7 @@ impl Default for HugrEngine { nodes_inside_tailloops: BTreeSet::new(), active_tailloops: BTreeMap::new(), pending_tailloop_control: BTreeSet::new(), + execution_error: None, // Result capture captured_results: Vec::new(), // WASM support @@ -1619,7 +2012,15 @@ impl ClassicalEngine for HugrEngine { .insert(wire_key, ClassicalValue::Bool(value != 0)); } } else { - debug!("No mapping for measurement index {global_idx}"); + // An outcome with no queued measurement to bind to + // means the driver and engine disagree about how + // many measurements this batch contained -- binding + // nothing silently corrupts every later index. + return Err(PecosError::Input(format!( + "measurement outcome {global_idx} has no queued measurement \ + (engine queued {}, driver sent {num_outcomes} in this batch)", + self.measurement_state.mappings.len() + ))); } } @@ -1639,10 +2040,27 @@ impl ClassicalEngine for HugrEngine { // results are available if let Some(hugr) = self.hugr.clone() { self.repropagate_measurement_values(&hugr); + // Likewise for expanded cases: a case can expand on its + // own control before OTHER data inputs (later + // measurements) exist; refresh its Input ports now. + self.repropagate_active_case_inputs(&hugr); + // And for launched calls whose argument was a + // measurement result still in flight (fill-only). + self.repropagate_active_call_inputs(&hugr); + // And for first-iteration tail loops in the same + // situation (fill-only, never past the first Continue). + self.repropagate_tailloop_initial_inputs(&hugr); + + // Replay can fill the very port a pending control was + // starving on (the resolver pass above ran before the + // fill), so give the resolvers a second look now. + self.try_resolve_pending_conditionals(); + self.try_resolve_pending_cfg_branches(); + self.try_resolve_pending_tailloops(); } // Retry any bool.read nodes that were waiting for measurement results - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); Ok(()) } @@ -1676,6 +2094,35 @@ impl ClassicalEngine for HugrEngine { .data .insert("measurements".to_string(), Data::from_u32_vec(values)); } + + // Pure-classical programs (no measurements either): surface the + // entrypoint's return values -- "return" for a single value, + // "return_{port}" for multiple. + if self.measurement_state.results.is_empty() && !self.return_values.is_empty() { + // Positional capture: return_values.len() IS the entrypoint's + // output arity (missing ports are None), so "return" applies + // exactly when the function returns one value, and every key + // carries the actual port index. + let single_return = self.return_values.len() == 1; + for (port, value) in self.return_values.iter().enumerate() { + let Some(value) = value else { continue }; + let data = match value { + ClassicalValue::Bool(b) => Data::Bool(*b), + ClassicalValue::Int(i) => Data::I64(*i), + ClassicalValue::UInt(u) => Data::U64(*u), + ClassicalValue::Float(f) => Data::F64(*f), + _ => { + debug!("return port {port}: non-scalar value {value:?} not surfaced"); + continue; + } + }; + if single_return { + result.data.insert("return".to_string(), data); + } else { + result.data.insert(format!("return_{port}"), data); + } + } + } } // Add captured results from result() calls @@ -1778,8 +2225,16 @@ impl Engine for HugrEngine { match stage { EngineStage::Complete(result) => Ok(result), EngineStage::NeedsProcessing(_) => { - debug!("HugrEngine cannot process quantum operations directly"); - Ok(self.get_results()?) + // The program emitted quantum commands, but this entry point + // has no quantum backend to run them: any results would be + // the pre-measurement partial state, silently wrong. The + // caller must drive the engine through `start`/`step` with a + // quantum engine attached instead. + Err(PecosError::Generic( + "HUGR program requires quantum processing; \ + Engine::process has no quantum backend to run it" + .to_string(), + )) } } } @@ -1849,6 +2304,1148 @@ mod tests { assert!(engine.quantum_ops.is_empty()); } + /// Build the RNG chain from tket-qsystem's own builder test and drive + /// the qsystem handlers over it, pinning the OUTPUT SHAPES to the + /// extension signatures: `NewRNGContext -> Option` (a Sum + /// with tag 1) and value-FIRST tuples for the Random* ops. The chain + /// seeds only + /// the constants and the unwrap Conditional's output; every later op + /// resolves its context from the previous op's stored port-1 output, so + /// a swapped port order fails the chain, not just one assert. + #[test] + #[allow(clippy::too_many_lines)] + fn test_rng_ops_emit_value_first_spec_shapes() { + use crate::engine::types::RngContextState; + use tket::hugr::builder::{Dataflow, DataflowHugr, FunctionBuilder}; + use tket::hugr::extension::prelude::{UnwrapBuilder, option_type}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::{Signature, Type}; + use tket_qsystem::extension::random::{CONTEXT_TYPE_NAME, EXTENSION, RandomOpBuilder}; + + let hugr = { + let mut fb = + FunctionBuilder::new("rng_chain", Signature::new(vec![], vec![int_type(5)])) + .unwrap(); + let seed = fb.add_load_const(Value::from(ConstInt::new_u(6, 123_456).unwrap())); + let maybe_ctx = fb.add_new_rng_context(seed).unwrap(); + let context_type = Type::from( + EXTENSION + .get_type(&CONTEXT_TYPE_NAME) + .unwrap() + .instantiate([]) + .unwrap(), + ); + let [ctx] = fb + .build_unwrap_sum(1, option_type(vec![context_type]), maybe_ctx) + .unwrap(); + let bound = fb.add_load_const(Value::from(ConstInt::new_u(5, 100).unwrap())); + let delta = fb.add_load_const(Value::from(ConstInt::new_s(6, -1).unwrap())); + let [_, ctx] = fb.add_random_int_bounded(ctx, bound).unwrap(); + let [_, ctx] = fb.add_random_float(ctx).unwrap(); + let ctx = fb.add_random_advance(ctx, delta).unwrap(); + let [rnd, ctx] = fb.add_random_int(ctx).unwrap(); + fb.add_delete_rng_context(ctx).unwrap(); + fb.finish_hugr_with_outputs([rnd]).unwrap() + }; + + let find = |name: &str| -> Node { + hugr.nodes() + .find(|n| { + hugr.get_optype(*n) + .as_extension_op() + .is_some_and(|op| op.unqualified_id() == name) + }) + .unwrap_or_else(|| panic!("no {name} node in the built chain")) + }; + let new_ctx = find("NewRNGContext"); + let bounded = find("RandomIntBounded"); + let float = find("RandomFloat"); + let advance = find("RandomAdvance"); + let int = find("RandomInt"); + let delete = find("DeleteRNGContext"); + + let seed_input = |engine: &mut HugrEngine, node: Node, port: usize, value| { + let (src, sp) = hugr + .single_linked_output(node, IncomingPort::from(port)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), value); + }; + + let mut engine = HugrEngine::default(); + + // NewRNGContext: u64 seed -> Some(context) + seed_input(&mut engine, new_ctx, 0, ClassicalValue::Int(123_456)); + assert_eq!( + engine.handle_random_op(&hugr, new_ctx, "NewRNGContext"), + HandlerOutcome::Processed + ); + let Some(ClassicalValue::Sum { tag: 1, values }) = engine + .wire_state + .classical_values + .get(&(new_ctx, 0)) + .cloned() + else { + panic!("NewRNGContext must produce Sum tag 1 (Some), got {:?}", { + engine.wire_state.classical_values.get(&(new_ctx, 0)) + }); + }; + let [ClassicalValue::RngContext(ctx_id)] = values.as_slice() else { + panic!("NewRNGContext Some payload must be an RNG context, got {values:?}"); + }; + + // The unwrap Conditional's output is engine-propagated in real runs; + // seed it directly here so the chain below starts from the context. + seed_input(&mut engine, bounded, 0, ClassicalValue::RngContext(*ctx_id)); + seed_input(&mut engine, bounded, 1, ClassicalValue::Int(100)); + assert_eq!( + engine.handle_random_op(&hugr, bounded, "RandomIntBounded"), + HandlerOutcome::Processed + ); + assert!(engine.execution_error.is_none()); + match engine.wire_state.classical_values.get(&(bounded, 0)) { + Some(ClassicalValue::Int(v)) => assert!((0..100).contains(v)), + other => panic!("RandomIntBounded port 0 must be the value, got {other:?}"), + } + assert!(matches!( + engine.wire_state.classical_values.get(&(bounded, 1)), + Some(ClassicalValue::RngContext(_)) + )); + + // RandomFloat/RandomAdvance/RandomInt/Delete each read the context + // from the PREVIOUS op's stored output -- no more seeding. + assert_eq!( + engine.handle_random_op(&hugr, float, "RandomFloat"), + HandlerOutcome::Processed + ); + match engine.wire_state.classical_values.get(&(float, 0)) { + Some(ClassicalValue::Float(f)) => assert!((0.0..1.0).contains(f)), + other => panic!("RandomFloat port 0 must be the value, got {other:?}"), + } + assert!(matches!( + engine.wire_state.classical_values.get(&(float, 1)), + Some(ClassicalValue::RngContext(_)) + )); + + // Advance then backtrack by the same delta must round-trip the + // stream exactly (xorshift64 jumps are exact in both directions). + let ctx_id_now = match engine.wire_state.classical_values.get(&(float, 1)) { + Some(ClassicalValue::RngContext(id)) => *id, + other => panic!("expected context after RandomFloat, got {other:?}"), + }; + let state_before = engine.extension_state.rng_contexts[&ctx_id_now].state; + seed_input(&mut engine, advance, 1, ClassicalValue::Int(1000)); + assert_eq!( + engine.handle_random_op(&hugr, advance, "RandomAdvance"), + HandlerOutcome::Processed + ); + assert_ne!( + engine.extension_state.rng_contexts[&ctx_id_now].state, + state_before + ); + seed_input(&mut engine, advance, 1, ClassicalValue::Int(-1000)); + assert_eq!( + engine.handle_random_op(&hugr, advance, "RandomAdvance"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.extension_state.rng_contexts[&ctx_id_now].state, state_before, + "advance(+1000) then advance(-1000) must round-trip" + ); + + assert_eq!( + engine.handle_random_op(&hugr, int, "RandomInt"), + HandlerOutcome::Processed + ); + match engine.wire_state.classical_values.get(&(int, 0)) { + Some(ClassicalValue::Int(v)) => { + // Canonical int<5> storage: the 32-bit value sign-extended, + // i.e. exactly the i32 range (NOT zero-extended [0, 2^32)). + assert!( + (i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(v), + "canonical int<32> value, got {v}" + ); + } + other => panic!("RandomInt port 0 must be the value, got {other:?}"), + } + assert!(matches!( + engine.wire_state.classical_values.get(&(int, 1)), + Some(ClassicalValue::RngContext(_)) + )); + + assert_eq!( + engine.handle_random_op(&hugr, delete, "DeleteRNGContext"), + HandlerOutcome::Processed + ); + assert!(engine.extension_state.rng_contexts.is_empty()); + + // An empty range has no value to produce: bound 0 must fault, not + // clamp. (Negative-looking bounds are canonical high-bit unsigned + // values and are VALID.) + let mut poisoned = HugrEngine::default(); + seed_input(&mut poisoned, bounded, 0, ClassicalValue::RngContext(7)); + poisoned + .extension_state + .rng_contexts + .insert(7, RngContextState::new(1)); + seed_input(&mut poisoned, bounded, 1, ClassicalValue::Int(0)); + let HandlerOutcome::Fault(fault) = + poisoned.handle_random_op(&hugr, bounded, "RandomIntBounded") + else { + panic!("bound 0 must fault"); + }; + assert!(fault.contains("empty range"), "unexpected fault: {fault}"); + } + + #[test] + fn test_ry_angle_tuple_runtime_execution() { + // End-to-end guard for the RUNTIME classical value chain of guppy's + // tuple-wrapped rotation angle (Const -> LoadConstant -> MakeTuple -> + // UnpackTuple -> from_halfturns_unchecked -> Ry). The tuple prelude + // ops execute as classical ops whose num_inputs must come from the + // dataflow signature -- the portgraph count includes the order port, + // which used to starve the chain and (before the fail-loud hardening) + // silently zero the angle. With the hardening, a starved chain makes + // this generate_commands call error instead of passing. + let hugr_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/ry_angle_tuple.hugr" + ); + let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); + + let msg = engine + .generate_commands() + .expect("Failed to generate commands"); + let ops = msg.quantum_ops().expect("Failed to parse quantum ops"); + + let ry_cmd = ops + .iter() + .find(|g| g.gate_type == GateType::RY) + .expect("Expected an RY command"); + assert_eq!(ry_cmd.angles.len(), 1, "RY command should carry its angle"); + let radians = ry_cmd.angles[0].to_radians(); + assert!( + (radians - std::f64::consts::FRAC_PI_2).abs() < 1e-9, + "RY command should have angle pi/2, got {radians}", + ); + + // Pin the RUNTIME chain directly: the gate command above can also be + // satisfied by the STATIC extraction (op.params wins in + // resolve_rotation_angle), so assert the classical values the runtime + // tuple ops must have produced during execution. + let hugr = engine.hugr.clone().expect("hugr present"); + let (unpack, from_halfturns) = ry_runtime_chain_nodes(&hugr); + assert_eq!( + engine.wire_state.classical_values.get(&(unpack, 0)), + Some(&ClassicalValue::Float(0.5)), + "UnpackTuple should have unpacked the angle float at runtime" + ); + assert_eq!( + engine.wire_state.classical_values.get(&(from_halfturns, 0)), + Some(&ClassicalValue::Rotation(0.5)), + "from_halfturns should have produced the runtime rotation value" + ); + } + + /// Walk back from `from_halfturns_unchecked` to its feeding `UnpackTuple` + /// in the guppy tuple-wrapped-angle fixtures. + fn ry_runtime_chain_nodes(hugr: &Hugr) -> (Node, Node) { + let mut from_halfturns = None; + for node in hugr.nodes() { + if let Some(ext) = hugr.get_optype(node).as_extension_op() + && ext.unqualified_id() == "from_halfturns_unchecked" + { + from_halfturns = Some(node); + } + } + let fh = from_halfturns.expect("fixture should contain from_halfturns_unchecked"); + let (unpack, _) = hugr + .single_linked_output(fh, IncomingPort::from(0)) + .expect("from_halfturns input should be wired"); + (unpack, fh) + } + + #[test] + fn test_ch_gate_full_execution_completes_cleanly() { + // End-to-end guard for classical value flow through nested function + // calls: guppy's ch() decomposes via a called function whose angle + // (pi/4) is computed by further calls over a tuple constant. The + // engine must (a) not fire a Call before its argument values exist, + // (b) run every Case/block classical op before propagating outputs, + // and (c) finish with NO active control flow left (leftover active + // entries mean a stall silently truncated the program). + use pecos_engines::{ByteMessageBuilder, ControlEngine, EngineStage}; + + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/ch_gate.hugr" + ); + let mut engine = HugrEngine::from_file(path).expect("Failed to load HUGR"); + let mut stage = engine.start(()).expect("Failed to start engine"); + let mut gate_counts: BTreeMap = BTreeMap::new(); + let mut rounds = 0; + loop { + rounds += 1; + assert!(rounds <= 10, "ch execution should complete in a few rounds"); + match stage { + EngineStage::NeedsProcessing(msg) => { + let ops = msg.quantum_ops().expect("parse quantum ops"); + for g in &ops { + *gate_counts.entry(g.gate_type).or_insert(0) += 1; + } + let n_meas = ops + .iter() + .filter(|g| { + matches!( + g.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) + }) + .count(); + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_outcomes(); + builder.add_outcomes(&vec![0usize; n_meas]); + stage = engine + .continue_processing(builder.build()) + .expect("continue"); + } + EngineStage::Complete(_) => break, + } + } + + // The CH decomposition: RY(pi/4), CZ, RY(-pi/4), then 2 measurements. + assert_eq!(gate_counts.get(&GateType::RY), Some(&2), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::CZ), Some(&1), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::MZ), Some(&2), "{gate_counts:?}"); + + // No stalled control flow and no starved nodes may remain. + assert!(engine.active_cases.is_empty(), "{:?}", engine.active_cases); + assert!( + engine.active_cfgs.is_empty(), + "cfgs: {:?}", + engine.active_cfgs.keys() + ); + assert!( + engine.active_calls.is_empty(), + "calls: {:?}", + engine.active_calls.keys() + ); + assert!(engine.active_tailloops.is_empty()); + assert!( + engine.deferred_nodes.is_empty(), + "starved nodes: {:?}", + engine.deferred_nodes + ); + } + + /// Build ipow / idivmod / itobool / ifrombool via the hugr std-arith + /// builders and drive the classical executor over them, pinning the + /// spec shapes: Euclidean (q, r) pairs on two ports, fail-loud m=0 for + /// the unchecked ops, error Sums for the checked ones, and wrapping + /// square-and-multiply exponentiation. + #[test] + #[allow(clippy::too_many_lines)] + fn test_missing_int_ops_execute_per_spec() { + use crate::engine::analysis::classify_classical_op; + use crate::engine::handlers::ClassicalOutcome; + use crate::engine::types::ClassicalOp; + use tket::hugr::builder::{Dataflow, DataflowHugr, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::conversions::ConvertOpDef; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let hugr = { + let mut fb = + FunctionBuilder::new("int_ops", Signature::new(vec![], vec![int_type(6)])).unwrap(); + let n = fb.add_load_const(Value::from(ConstInt::new_s(6, -7).unwrap())); + let m = fb.add_load_const(Value::from(ConstInt::new_u(6, 3).unwrap())); + let base = fb.add_load_const(Value::from(ConstInt::new_s(6, 3).unwrap())); + let exp = fb.add_load_const(Value::from(ConstInt::new_u(6, 4).unwrap())); + let bit = fb.add_load_const(Value::from(ConstInt::new_u(0, 1).unwrap())); + let flag = fb.add_load_const(Value::true_val()); + let [pow] = fb + .add_dataflow_op(IntOpDef::ipow.with_log_width(6), [base, exp]) + .unwrap() + .outputs_arr(); + let [_q, _r] = fb + .add_dataflow_op(IntOpDef::idivmod_s.with_log_width(6), [n, m]) + .unwrap() + .outputs_arr(); + let [_qr_sum] = fb + .add_dataflow_op(IntOpDef::idivmod_checked_s.with_log_width(6), [n, m]) + .unwrap() + .outputs_arr(); + let [_b] = fb + .add_dataflow_op(ConvertOpDef::itobool.without_log_width(), [bit]) + .unwrap() + .outputs_arr(); + let [_i] = fb + .add_dataflow_op(ConvertOpDef::ifrombool.without_log_width(), [flag]) + .unwrap() + .outputs_arr(); + fb.finish_hugr_with_outputs([pow]).unwrap() + }; + + let find = |name: &str| -> Node { + hugr.nodes() + .find(|n| { + hugr.get_optype(*n) + .as_extension_op() + .is_some_and(|op| op.unqualified_id() == name) + }) + .unwrap_or_else(|| panic!("no {name} node")) + }; + let mut engine = HugrEngine::default(); + let run = |engine: &mut HugrEngine, + name: &str, + seeds: &[(usize, ClassicalValue)]| + -> ClassicalOutcome { + let node = find(name); + for (port, value) in seeds { + let (src, sp) = hugr + .single_linked_output(node, IncomingPort::from(*port)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), value.clone()); + } + let (op_type, num_inputs, num_outputs, int_info) = + classify_classical_op(hugr.get_optype(node)) + .unwrap_or_else(|| panic!("{name} must classify")); + let op = ClassicalOp { + node, + op_type, + num_inputs, + num_outputs, + int_info, + const_value: None, + }; + engine.handle_classical_op(&hugr, node, &op) + }; + + // ipow: 3^4 = 81 + let got = run( + &mut engine, + "ipow", + &[(0, ClassicalValue::Int(3)), (1, ClassicalValue::Int(4))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(81))]) + ); + + // idivmod_s: Euclidean -7 divmod 3 -> (q, r) = (-3, 2) on two ports + let got = run( + &mut engine, + "idivmod_s", + &[(0, ClassicalValue::Int(-7)), (1, ClassicalValue::Int(3))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![ + (0, ClassicalValue::Int(-3)), + (1, ClassicalValue::Int(2)) + ]) + ); + + // idivmod_s with a "negative" divisor: the divisor port is UNSIGNED + // per the spec, so canonical -3 reads as its bit pattern (2^64 - 3) + // and the huge divisor makes q = 0, r = the dividend. This pins the + // RAW OP's semantics (what guppy-compiled programs observe, validated + // against the Selene reference) -- NOT Python-level `//`, which a + // frontend handling divisor sign separately would layer on top. + let got = run( + &mut engine, + "idivmod_s", + &[(0, ClassicalValue::Int(7)), (1, ClassicalValue::Int(-3))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![ + (0, ClassicalValue::Int(0)), + (1, ClassicalValue::Int(7)) + ]) + ); + + // idivmod_s by zero: fatal fault per the spec + let got = run( + &mut engine, + "idivmod_s", + &[(0, ClassicalValue::Int(-7)), (1, ClassicalValue::Int(0))], + ); + assert!( + matches!(got, ClassicalOutcome::Fault(ref msg) if msg.contains("division by zero")), + "expected div-by-zero fault, got {got:?}" + ); + + // idivmod_checked_s: value = Sum tag 1 with a (q, r) tuple payload + let got = run( + &mut engine, + "idivmod_checked_s", + &[(0, ClassicalValue::Int(-7)), (1, ClassicalValue::Int(3))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![( + 0, + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Tuple(vec![ + ClassicalValue::Int(-3), + ClassicalValue::Int(2) + ])], + } + )]) + ); + + // idivmod_checked_s by zero: error Sum (tag 0) with the opaque + // error payload sum_with_error's error variant carries + let got = run( + &mut engine, + "idivmod_checked_s", + &[(0, ClassicalValue::Int(-7)), (1, ClassicalValue::Int(0))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![( + 0, + ClassicalValue::Sum { + tag: 0, + values: vec![ClassicalValue::Tuple(vec![])] + } + )]) + ); + + // itobool / ifrombool + let got = run(&mut engine, "itobool", &[(0, ClassicalValue::Int(1))]); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Bool(true))]) + ); + // ifrombool produces int<1>; the canonical (sign-extended) storage + // of a 1-bit "1" is -1, matching ConstInt::value_s parsing. A + // round-trip through itobool still reads it as true. + let got = run(&mut engine, "ifrombool", &[(0, ClassicalValue::Bool(true))]); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(-1))]) + ); + let got = run(&mut engine, "itobool", &[(0, ClassicalValue::Int(-1))]); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Bool(true))]) + ); + } + + /// int<5> (32-bit) semantics: wrapping addition, shift-out-to-zero, + /// rotation within 32 bits, and leading-zero counts relative to the + /// width -- all derived from the op's `BoundedNat` type arg rather than + /// the engine's 64-bit storage. + #[test] + fn test_int_width_modeling_32bit() { + use crate::engine::analysis::classify_classical_op; + use crate::engine::handlers::{ClassicalOutcome, HandlerOutcome}; + use crate::engine::types::ClassicalOp; + use tket::hugr::builder::{Dataflow, DataflowHugr, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let hugr = { + let mut fb = + FunctionBuilder::new("w32", Signature::new(vec![], vec![int_type(5)])).unwrap(); + let a = fb.add_load_const(Value::from(ConstInt::new_u(5, 0x7FFF_FFFF).unwrap())); + let b = fb.add_load_const(Value::from(ConstInt::new_u(5, 1).unwrap())); + let [sum] = fb + .add_dataflow_op(IntOpDef::iadd.with_log_width(5), [a, b]) + .unwrap() + .outputs_arr(); + let [_shifted] = fb + .add_dataflow_op(IntOpDef::ishl.with_log_width(5), [a, b]) + .unwrap() + .outputs_arr(); + let [_rot] = fb + .add_dataflow_op(IntOpDef::irotl.with_log_width(5), [a, b]) + .unwrap() + .outputs_arr(); + fb.finish_hugr_with_outputs([sum]).unwrap() + }; + + let find = |name: &str| -> Node { + hugr.nodes() + .find(|n| { + hugr.get_optype(*n) + .as_extension_op() + .is_some_and(|op| op.unqualified_id() == name) + }) + .unwrap_or_else(|| panic!("no {name} node")) + }; + let mut engine = HugrEngine::default(); + let seed = |engine: &mut HugrEngine, node: Node, port: usize, value: ClassicalValue| { + let (src, sp) = hugr + .single_linked_output(node, IncomingPort::from(port)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), value); + }; + let classical = |node: Node| -> ClassicalOp { + let (op_type, num_inputs, num_outputs, int_info) = + classify_classical_op(hugr.get_optype(node)).expect("classifies"); + ClassicalOp { + node, + op_type, + num_inputs, + num_outputs, + int_info, + const_value: None, + } + }; + + // iadd at 32 bits: i32::MAX + 1 wraps to i32::MIN (canonical + // sign-extended storage) + let node = find("iadd"); + seed(&mut engine, node, 0, ClassicalValue::Int(0x7FFF_FFFF)); + seed(&mut engine, node, 1, ClassicalValue::Int(1)); + assert_eq!( + engine.handle_classical_op(&hugr, node, &classical(node)), + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(i64::from(i32::MIN)))]) + ); + + // ishl at 32 bits: 1 << 31 = i32::MIN; 1 << 32 drops every bit + let node = find("ishl"); + seed(&mut engine, node, 0, ClassicalValue::Int(1)); + seed(&mut engine, node, 1, ClassicalValue::Int(31)); + assert_eq!( + engine.handle_classical_op(&hugr, node, &classical(node)), + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(i64::from(i32::MIN)))]) + ); + seed(&mut engine, node, 1, ClassicalValue::Int(32)); + assert_eq!( + engine.handle_classical_op(&hugr, node, &classical(node)), + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(0))]) + ); + + // irotl at 32 bits: rotating 0x8000_0001 left by 1 gives + // 0x0000_0003 (the high bit wraps into bit 0 within 32 bits) + let node = find("irotl"); + seed( + &mut engine, + node, + 0, + ClassicalValue::Int(i64::from(u32::from_le_bytes([1, 0, 0, 0x80]).cast_signed())), + ); + seed(&mut engine, node, 1, ClassicalValue::Int(1)); + assert_eq!( + engine.handle_int_op(&hugr, node, "irotl"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.wire_state.classical_values.get(&(node, 0)), + Some(&ClassicalValue::Int(3)) + ); + } + + /// Excess measurement outcomes (driver/engine batch-count disagreement) + /// must raise instead of silently dropping -- an unbound outcome shifts + /// every later measurement index. + #[test] + fn test_excess_measurement_outcomes_error() { + use pecos_engines::{ByteMessageBuilder, ControlEngine, EngineStage}; + + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/single_hadamard.hugr" + ); + let mut engine = HugrEngine::from_file(path).expect("Failed to load HUGR"); + let stage = engine.start(()).expect("start"); + let EngineStage::NeedsProcessing(msg) = stage else { + panic!("expected a processing stage"); + }; + let n_meas = msg + .quantum_ops() + .expect("parse ops") + .iter() + .filter(|g| matches!(g.gate_type, GateType::MZ)) + .count(); + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_outcomes(); + builder.add_outcomes(&vec![0usize; n_meas + 3]); + let Err(err) = engine.continue_processing(builder.build()) else { + panic!("excess outcomes must error"); + }; + assert!( + err.to_string().contains("has no queued measurement"), + "unexpected error: {err}" + ); + } + + /// Round-8 regressions: unsigned width conversions must reinterpret + /// the canonical (sign-extended) storage as a bit pattern at the + /// SOURCE width -- as_uint-style negative rejection deferred a + /// canonical `int<1>` "1" (stored -1) forever, and `inarrow_u`'s signed + /// range test rejected legitimate high-bit unsigned values. + #[test] + fn test_unsigned_width_conversions_handle_canonical_storage() { + use crate::engine::handlers::HandlerOutcome; + use tket::hugr::builder::{Dataflow, DataflowHugr, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let hugr = { + let mut fb = + FunctionBuilder::new("widen", Signature::new(vec![], vec![int_type(6)])).unwrap(); + let bit = fb.add_load_const(Value::from(ConstInt::new_u(0, 1).unwrap())); + let wide = fb.add_load_const(Value::from(ConstInt::new_u(6, 1).unwrap())); + let [widened] = fb + .add_dataflow_op(IntOpDef::iwiden_u.with_two_log_widths(0, 6), [bit]) + .unwrap() + .outputs_arr(); + let [_narrowed] = fb + .add_dataflow_op(IntOpDef::inarrow_u.with_two_log_widths(6, 5), [wide]) + .unwrap() + .outputs_arr(); + let _ = widened; + fb.finish_hugr_with_outputs([widened]).unwrap() + }; + let find = |name: &str| -> Node { + hugr.nodes() + .find(|n| { + hugr.get_optype(*n) + .as_extension_op() + .is_some_and(|op| op.unqualified_id() == name) + }) + .unwrap_or_else(|| panic!("no {name} node")) + }; + let seed = |engine: &mut HugrEngine, node: Node, value: ClassicalValue| { + let (src, sp) = hugr + .single_linked_output(node, IncomingPort::from(0)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), value); + }; + + let mut engine = HugrEngine::default(); + + // iwiden_u int<1> -> int<64>: canonical 1-bit "1" stores as -1 and + // must widen (zero-extend) to 1, not defer or become u64::MAX. + let widen = find("iwiden_u"); + seed(&mut engine, widen, ClassicalValue::Int(-1)); + assert_eq!( + engine.handle_int_op(&hugr, widen, "iwiden_u"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.wire_state.classical_values.get(&(widen, 0)), + Some(&ClassicalValue::Int(1)) + ); + + // inarrow_u int<64> -> int<32> of 0xFFFF_FFFF: fits as unsigned, + // and the narrowed value stores canonically (sign-extended) as -1. + let narrow = find("inarrow_u"); + seed(&mut engine, narrow, ClassicalValue::Int(0xFFFF_FFFF)); + assert_eq!( + engine.handle_int_op(&hugr, narrow, "inarrow_u"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.wire_state.classical_values.get(&(narrow, 0)), + Some(&ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(-1)] + }) + ); + + // inarrow_u of a value ABOVE 2^32 must produce the error variant + // (with its opaque payload), not a fault and not a fit. + seed(&mut engine, narrow, ClassicalValue::Int(0x1_0000_0000)); + assert_eq!( + engine.handle_int_op(&hugr, narrow, "inarrow_u"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.wire_state.classical_values.get(&(narrow, 0)), + Some(&ClassicalValue::Sum { + tag: 0, + values: vec![ClassicalValue::Tuple(vec![])] + }) + ); + } + + /// A DEAD (uncalled, non-entrypoint) module-level function must not + /// execute: its body used to be ungated (only CALLED `FuncDefns` were + /// gated), so it raced the real program and could clobber the + /// entrypoint's captured return values. + #[test] + fn test_dead_function_body_does_not_execute() { + use tket::hugr::builder::{Container, Dataflow, DataflowSubContainer, ModuleBuilder}; + use tket::hugr::hugr::hugrmut::HugrMut; + use tket::hugr::ops::Value; + use tket::hugr::ops::handle::NodeHandle; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let (hugr, dead_load) = { + let mut module = ModuleBuilder::new(); + let mut main_fb = module + .define_function("main", Signature::new(vec![], vec![int_type(6)])) + .unwrap(); + let seven = main_fb.add_load_const(Value::from(ConstInt::new_u(6, 7).unwrap())); + let main_id = main_fb.finish_with_outputs([seven]).unwrap(); + let mut dead_fb = module + .define_function("dead", Signature::new(vec![], vec![int_type(6)])) + .unwrap(); + let nine = dead_fb.add_load_const(Value::from(ConstInt::new_u(6, 9).unwrap())); + let dead_load = nine.node(); + dead_fb.finish_with_outputs([nine]).unwrap(); + let mut hugr = module.hugr().clone(); + hugr.set_entrypoint(main_id.node()); + (hugr, dead_load) + }; + + let engine = HugrEngine::from_hugr(hugr); + assert!( + engine.nodes_inside_func_defns.contains(&dead_load), + "dead function body must be gated" + ); + assert!( + !engine.work_queue.contains(dead_load), + "dead function body must not be queued" + ); + } + + /// xorshift64 jump-ahead must be EXACT: M^k over GF(2) equals k + /// sequential steps, and backward jumps invert them via the 2^64-1 + /// period. + #[test] + fn test_rng_jump_matches_stepping() { + use crate::engine::types::RngContextState; + + let mut stepped = RngContextState::new(0xDEAD_BEEF); + let mut jumped = RngContextState::new(0xDEAD_BEEF); + for _ in 0..137 { + stepped.next_u64(); + } + jumped.jump(137); + assert_eq!(stepped.state, jumped.state); + + jumped.jump_back(137); + assert_eq!(jumped.state, RngContextState::new(0xDEAD_BEEF).state); + + // Identity jump. + let before = stepped.state; + stepped.jump(0); + assert_eq!(stepped.state, before); + } + + /// Classical values must cross DFG boundaries structurally (the + /// flattening semantics qubit tracing already had): a consumer inside + /// a nested DFG reads through the Input boundary to the outer + /// producer, and a consumer of the DFG node reads through its Output + /// child. + #[test] + fn test_classical_values_trace_through_dfg_boundaries() { + use tket::hugr::builder::{Dataflow, DataflowHugr, DataflowSubContainer, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::ops::handle::NodeHandle; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let (hugr, iadd_node, dfg_node) = { + let mut fb = + FunctionBuilder::new("dfg_flow", Signature::new(vec![], vec![int_type(6)])) + .unwrap(); + let a = fb.add_load_const(Value::from(ConstInt::new_u(6, 30).unwrap())); + let b = fb.add_load_const(Value::from(ConstInt::new_u(6, 12).unwrap())); + let mut dfg = fb + .dfg_builder( + Signature::new(vec![int_type(6); 2], vec![int_type(6)]), + [a, b], + ) + .unwrap(); + let [ia, ib] = dfg.input_wires_arr(); + let [sum] = dfg + .add_dataflow_op(IntOpDef::iadd.with_log_width(6), [ia, ib]) + .unwrap() + .outputs_arr(); + let iadd_node = sum.node(); + let dfg_handle = dfg.finish_with_outputs([sum]).unwrap(); + let dfg_node = dfg_handle.node(); + let [out] = dfg_handle.outputs_arr(); + let hugr = fb.finish_hugr_with_outputs([out]).unwrap(); + (hugr, iadd_node, dfg_node) + }; + + let mut engine = HugrEngine::default(); + // Seed the OUTER producers (the LoadConstant wires feeding the DFG). + for (port, value) in [(0, 30i64), (1, 12i64)] { + let (src, sp) = hugr + .single_linked_output(dfg_node, IncomingPort::from(port)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), ClassicalValue::Int(value)); + } + + // Inside: the iadd's inputs read THROUGH the DFG Input boundary. + assert_eq!( + engine.get_input_value(&hugr, iadd_node, 0), + Some(ClassicalValue::Int(30)) + ); + assert_eq!( + engine.get_input_value(&hugr, iadd_node, 1), + Some(ClassicalValue::Int(12)) + ); + + // Outside: once the interior op stores its result, a consumer of + // the DFG node reads THROUGH its Output child. The function's + // Output node consumes the DFG's port 0. + engine + .wire_state + .classical_values + .insert((iadd_node, 0), ClassicalValue::Int(42)); + let func_output = hugr + .get_io(hugr.get_parent(dfg_node).unwrap()) + .map(|[_, o]| o) + .unwrap(); + assert_eq!( + engine.get_input_value(&hugr, func_output, 0), + Some(ClassicalValue::Int(42)) + ); + } + + /// End-to-end companion to the tracing test above: the EXECUTOR + /// (`handle_classical_op` via the work-queue dispatch, not the + /// `get_input_value` helper in isolation) must resolve a classical op's + /// inputs across a flattened-DFG boundary. Before the executor used + /// the tracing layer it raw-read the source wire, so the iadd inside + /// the DFG deferred forever and this run failed as a stall. + #[test] + fn test_classical_op_inside_dfg_executes_end_to_end() { + use tket::hugr::builder::{Dataflow, DataflowHugr, DataflowSubContainer, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let (hugr, iadd_node) = { + let mut fb = + FunctionBuilder::new("dfg_exec", Signature::new(vec![], vec![int_type(6)])) + .unwrap(); + let a = fb.add_load_const(Value::from(ConstInt::new_u(6, 30).unwrap())); + let b = fb.add_load_const(Value::from(ConstInt::new_u(6, 12).unwrap())); + let mut dfg = fb + .dfg_builder( + Signature::new(vec![int_type(6); 2], vec![int_type(6)]), + [a, b], + ) + .unwrap(); + let [ia, ib] = dfg.input_wires_arr(); + let [sum] = dfg + .add_dataflow_op(IntOpDef::iadd.with_log_width(6), [ia, ib]) + .unwrap() + .outputs_arr(); + let iadd_node = sum.node(); + let dfg_handle = dfg.finish_with_outputs([sum]).unwrap(); + let [out] = dfg_handle.outputs_arr(); + let hugr = fb.finish_hugr_with_outputs([out]).unwrap(); + (hugr, iadd_node) + }; + + let mut engine = HugrEngine::from_hugr(hugr); + engine + .generate_commands() + .expect("pure-classical DFG program must complete without stalling"); + assert_eq!( + engine.wire_state.classical_values.get(&(iadd_node, 0)), + Some(&ClassicalValue::Int(42)), + "iadd inside the DFG must execute with values traced across the boundary" + ); + } + + #[test] + fn test_completion_audit_reports_unexecuted_container_ops() { + // The reachability audit must catch a container the engine claims + // to have executed whose ops never ran -- the class of bug that is + // invisible to queue/pending bookkeeping (a node that was never + // queued at all). Simulate one by recording a real block as + // executed without running anything. + use tket::hugr::ops::OpType; + + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/forloop_h_test.hugr" + ); + let mut engine = HugrEngine::from_file(path).expect("Failed to load HUGR"); + let hugr = engine.hugr.clone().expect("hugr loaded"); + let block = hugr + .nodes() + .find(|n| { + matches!(hugr.get_optype(*n), OpType::DataflowBlock(_)) + && hugr.children(*n).any(|c| { + !matches!( + hugr.get_optype(c), + OpType::Input(_) | OpType::Output(_) | OpType::Const(_) + ) + }) + }) + .expect("fixture has a non-trivial block"); + engine.executed_containers.insert(block, "DataflowBlock"); + + let err = engine + .ensure_no_stalled_execution() + .expect_err("audit must fail with unexecuted ops"); + let msg = format!("{err}"); + assert!( + msg.contains("unexecuted ops in executed containers"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_forloop_executes_each_iteration_and_terminates() { + // Regression guard for the loop-iteration freeze: guppy's + // `for _ in range(3)` lowers to a CFG cycle whose body block calls + // the iterator's __next__ each pass. Block re-activation must clear + // processed flags for ALL op categories BEFORE any readiness check; + // interleaving them let the Call fire against the previous + // iteration's flags, re-propagating stale arguments so the loop + // re-ran iteration 0 forever (H emitted per wave, no termination). + use pecos_engines::{ByteMessageBuilder, ControlEngine, EngineStage}; + + let _ = env_logger::builder().is_test(true).try_init(); + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/forloop_h_test.hugr" + ); + let mut engine = HugrEngine::from_file(path).expect("Failed to load HUGR"); + let mut stage = engine.start(()).expect("Failed to start engine"); + let mut gate_counts: BTreeMap = BTreeMap::new(); + let mut rounds = 0; + loop { + rounds += 1; + assert!( + rounds <= 10, + "forloop should terminate in a few rounds; gate_counts={gate_counts:?}" + ); + match stage { + EngineStage::NeedsProcessing(msg) => { + let ops = msg.quantum_ops().expect("parse quantum ops"); + for g in &ops { + *gate_counts.entry(g.gate_type).or_insert(0) += 1; + } + let n_meas = ops + .iter() + .filter(|g| { + matches!( + g.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) + }) + .count(); + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_outcomes(); + builder.add_outcomes(&vec![0usize; n_meas]); + stage = engine + .continue_processing(builder.build()) + .expect("continue"); + } + EngineStage::Complete(_) => break, + } + } + + // range(3): exactly three H applications, then the final measure. + assert_eq!(gate_counts.get(&GateType::H), Some(&3), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::MZ), Some(&1), "{gate_counts:?}"); + + // Clean completion: no stalled control flow, no starved nodes. + assert!(engine.active_cfgs.is_empty()); + assert!(engine.active_cases.is_empty()); + assert!(engine.active_calls.is_empty()); + assert!(engine.active_tailloops.is_empty()); + assert!(engine.deferred_nodes.is_empty()); + } + + #[test] + fn test_ry_angle_through_tuple_wrap() { + // Guppy lowers `ry(q, angle(0.5))` with the angle constant wrapped in + // a 1-tuple (Const -> LoadConstant -> MakeTuple -> UnpackTuple -> + // from_halfturns_unchecked -> Ry). The static angle extraction must + // trace through the tuple wrap/unwrap; a miss used to silently become + // RY(0) (issue observed as all-|0> Bell statistics in + // test_real_quantum_circuits.py::test_rotation_gates). + let hugr_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/ry_angle_tuple.hugr" + ); + let engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); + + let ry_ops: Vec<_> = engine + .quantum_ops + .values() + .filter(|op| op.gate_type == GateType::RY) + .collect(); + assert_eq!(ry_ops.len(), 1, "Expected exactly one RY op"); + + // angle(0.5) = 0.5 half-turns = pi/2 radians. + let params = &ry_ops[0].params; + assert_eq!( + params.len(), + 1, + "RY angle was not statically extracted (tuple wrap/unwrap not traced)" + ); + let angle = params[0]; + assert!( + (angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12, + "RY angle should be pi/2 radians, got {angle}" + ); + } + + #[test] + fn test_rx_pi_tuple_const_runtime_execution() { + // Like test_ry_angle_tuple_runtime_execution, but for guppy's `pi` + // constant, which lowers the angle as a TUPLE-VALUED Const + // (Const(Tuple(FloatVal)) -> LoadConstant -> UnpackTuple -> + // from_halfturns_unchecked -> Rx) with no MakeTuple node. The runtime + // constant loader must convert tuple constants element-wise or the + // chain starves and (with the fail-loud hardening) this errors. + let hugr_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/rx_pi_tuple_const.hugr" + ); + let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); + + let msg = engine + .generate_commands() + .expect("Failed to generate commands"); + let ops = msg.quantum_ops().expect("Failed to parse quantum ops"); + + let rx_cmd = ops + .iter() + .find(|g| g.gate_type == GateType::RX) + .expect("Expected an RX command"); + assert_eq!(rx_cmd.angles.len(), 1, "RX command should carry its angle"); + let radians = rx_cmd.angles[0].to_radians(); + assert!( + (radians - std::f64::consts::PI).abs() < 1e-9, + "RX command should have angle pi, got {radians}", + ); + } + #[test] fn test_load_single_hadamard() { // Load the single_hadamard.hugr test file @@ -2594,55 +4191,58 @@ mod tests { let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); - // Start execution - let stage = engine.start(()).expect("Failed to start engine"); - - match stage { - pecos_engines::EngineStage::NeedsProcessing(msg) => { - println!("Stage 1: NeedsProcessing"); - if let Ok(ops) = msg.quantum_ops() { - println!( - " Operations: {:?}", - ops.iter().map(|o| o.gate_type).collect::>() - ); - } - - // Simulate measurement result (0 = else branch, 1 = if branch) - // Create a mock measurement result - let mut builder = ByteMessageBuilder::new(); - let _ = builder.for_outcomes(); - builder.add_outcomes(&[0]); // Measure 0, take else branch - let measurement_msg = builder.build(); - - // Continue processing with the measurement result - let stage2 = engine - .continue_processing(measurement_msg) - .expect("Failed to continue"); - - match stage2 { - pecos_engines::EngineStage::NeedsProcessing(msg2) => { - println!("Stage 2: NeedsProcessing (more ops after conditional)"); - if let Ok(ops) = msg2.quantum_ops() { - println!( - " Operations: {:?}", - ops.iter().map(|o| o.gate_type).collect::>() - ); - } - } - pecos_engines::EngineStage::Complete(result) => { - println!("Stage 2: Complete"); - println!(" Result: {result:?}"); + // Drive with one outcome per measurement (all zeros). The + // result-reporting tail maps a function over the measured array via + // collections.borrow_arr.scan; with scan support the program runs + // to clean completion and captures the reported result array. + let mut stage = engine.start(()).expect("Failed to start engine"); + let mut gate_counts: BTreeMap = BTreeMap::new(); + let mut rounds = 0; + loop { + rounds += 1; + assert!(rounds <= 20, "conditional_x should complete quickly"); + match stage { + pecos_engines::EngineStage::NeedsProcessing(msg) => { + let ops = msg.quantum_ops().expect("parse quantum ops"); + for g in &ops { + *gate_counts.entry(g.gate_type).or_insert(0) += 1; } + let n_meas = ops + .iter() + .filter(|g| { + matches!( + g.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) + }) + .count(); + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_outcomes(); + builder.add_outcomes(&vec![0usize; n_meas]); + stage = engine + .continue_processing(builder.build()) + .expect("conditional_x must complete cleanly under scan support"); } - } - pecos_engines::EngineStage::Complete(result) => { - println!("Stage 1: Complete (no quantum ops needed)"); - println!(" Result: {result:?}"); + pecos_engines::EngineStage::Complete(_) => break, } } - // The test passes if we get here without panicking - // Full correctness requires integration with a quantum simulator + // With all-zero outcomes the measurement selects the else branch: + // H on the control, two measurements, and NO conditional X. + assert_eq!(gate_counts.get(&GateType::H), Some(&1), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::MZ), Some(&2), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::X), None, "{gate_counts:?}"); + + // The scan-driven reporting tail must produce the result array: + // both measured bits are 0. + assert!(engine.active_scans.is_empty()); + let captured = engine.get_captured_results(); + assert!( + captured.iter().any( + |r| matches!(&r.value, ResultValue::ArrayBool(bits) if bits == &vec![false, false]) + ), + "expected a [false, false] result array, got {captured:?}" + ); } // --- Integration Tests with Quantum Simulator --- @@ -3500,12 +5100,16 @@ mod tests { println!("While loop results: {successes} successes, {failures} failures"); - // For now, just check that we can load and attempt to run - // Full while loop support may require additional work for CFG back edges - assert!( - successes > 0 || failures > 0, - "Should have attempted at least some shots" + // Every shot must succeed: while-loop execution over CFG back edges + // is supported (the old `successes > 0 || failures > 0` form was a + // tautology that passed with 10/10 failed shots). + assert_eq!( + failures, + 0, + "while-loop shots failed: {failures}/{}", + successes + failures ); + assert!(successes > 0, "no shots ran"); } #[test] @@ -3606,11 +5210,8 @@ mod tests { // With H gate, should be roughly 50/50 // Allow for statistical variance - assert!( - failures < num_shots, - "All shots failed - function call not working" - ); - if failures == 0 { + assert_eq!(failures, 0, "shots failed: {failures}/{num_shots}"); + { // Check distribution only if all shots succeeded let total = count_0 + count_1; assert!(total > 0, "No measurements recorded"); @@ -3747,11 +5348,8 @@ mod tests { ); // With two independent H gates, should see roughly 25% each - assert!( - failures < num_shots, - "All shots failed - multiple function calls not working" - ); - if failures == 0 { + assert_eq!(failures, 0, "shots failed: {failures}/{num_shots}"); + { let total = count_00 + count_01 + count_10 + count_11; assert!(total > 0, "No measurements recorded"); // Each outcome should be roughly 25% (allow 10-40%) @@ -3859,11 +5457,8 @@ mod tests { ); // With H gate (through nested calls), should be roughly 50/50 - assert!( - failures < num_shots, - "All shots failed - nested function calls not working" - ); - if failures == 0 { + assert_eq!(failures, 0, "shots failed: {failures}/{num_shots}"); + { let total = count_0 + count_1; assert!(total > 0, "No measurements recorded"); let ratio = f64::from(count_0) / f64::from(total); @@ -3975,11 +5570,8 @@ mod tests { ); // Bell state: should only see 00 or 11 (correlated measurements) - assert!( - failures < num_shots, - "All shots failed - multi-qubit function not working" - ); - if failures == 0 { + assert_eq!(failures, 0, "shots failed: {failures}/{num_shots}"); + { let total = count_00 + count_01 + count_10 + count_11; assert!(total > 0, "No measurements recorded"); @@ -3990,6 +5582,10 @@ mod tests { correlated > uncorrelated * 4, "Expected Bell state correlation: {correlated} correlated vs {uncorrelated} uncorrelated" ); + // A zero-gate engine also satisfies the ratio (all shots 00): + // a real Bell state must produce BOTH correlated outcomes. + assert!(count_00 > 0, "expected some 00 outcomes"); + assert!(count_11 > 0, "expected some 11 outcomes"); } } } diff --git a/crates/pecos-hugr/src/engine/activation.rs b/crates/pecos-hugr/src/engine/activation.rs new file mode 100644 index 000000000..7aa4611aa --- /dev/null +++ b/crates/pecos-hugr/src/engine/activation.rs @@ -0,0 +1,169 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Container activation as a mechanism: the two-phase discipline. +//! +//! Every container (re-)activation -- CFG entry blocks and transitions, +//! Conditional case expansion, `TailLoop` expansion and continuation, and +//! Call frame re-activation -- must clear ALL stale state (processed flags, +//! output wires) BEFORE any readiness check queues work. Readiness +//! (`all_predecessors_ready`) consults the processed set, so interleaving +//! clear-and-queue per op category lets a consumer pass its readiness check +//! against a not-yet-cleared producer's previous-iteration flags, fire +//! early, and copy stale or missing values (the historical loop-freeze +//! class of bugs). +//! +//! Sites build a [`ContainerActivation`] batch with their own selection +//! logic (which nodes, in which order, under which queue policy); +//! [`HugrEngine::run_activation`] enforces the phase ordering so no site +//! can get it wrong again. + +use std::collections::BTreeSet; + +use tket::hugr::{Hugr, HugrView, Node}; + +use crate::engine::HugrEngine; + +/// Whether a node queues unconditionally or only once its predecessors are +/// processed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum QueuePolicy { + /// Queue only when `all_predecessors_ready` passes. For nodes that copy + /// their inputs at fire time (Calls, classical/extension ops): firing + /// early copies missing values and starves everything downstream. + IfReady, + /// Queue unconditionally. For nodes that defer internally until their + /// inputs resolve (Conditionals, `TailLoops`, bool reads) or that have no + /// dataflow inputs at all (`LoadConstants`). + Always, +} + +/// A batched container activation. Build with the site's selection logic, +/// then apply with [`HugrEngine::run_activation`]. +#[derive(Debug, Default)] +pub(crate) struct ContainerActivation { + /// Nodes whose processed flag clears in phase 1. + reset_processed: Vec, + /// Nodes whose stale output wires clear in phase 1. + reset_wires: Vec, + /// Wire-clear exemptions (nodes holding freshly propagated values, + /// e.g. a block or loop-body Input node). + keep_wires: BTreeSet, + /// Nodes released from the CFG-block gate only, without queueing + /// (e.g. block-level tracking of ops that an inner `TailLoop` still + /// owns and will queue at its own expansion). + ungate_block_only: Vec, + /// Nodes released from every container gate without queueing. + ungate_all: Vec, + /// Nodes to queue in phase 2, in submission order. + queue: Vec<(Node, QueuePolicy)>, +} + +impl ContainerActivation { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Phase 1: clear this node's processed flag AND stale output wires. + pub(crate) fn reset(&mut self, node: Node) { + self.reset_processed.push(node); + self.reset_wires.push(node); + } + + /// Phase 1: clear only this node's processed flag. + pub(crate) fn reset_processed(&mut self, node: Node) { + self.reset_processed.push(node); + } + + /// Phase 1: clear only this node's stale output wires. + pub(crate) fn reset_wires(&mut self, node: Node) { + self.reset_wires.push(node); + } + + /// Exempt a node from wire clearing (it holds freshly propagated + /// values). + pub(crate) fn keep_wires(&mut self, node: Node) { + self.keep_wires.insert(node); + } + + /// Release a node from the CFG-block gate without queueing it: an + /// inner container still owns it and queues it at its own expansion. + pub(crate) fn ungate_block_only(&mut self, node: Node) { + self.ungate_block_only.push(node); + } + + /// Release a node from EVERY container gate without queueing it (e.g. + /// case quantum ops queued separately as entry nodes, or ops that only + /// the retry path may queue). + pub(crate) fn ungate(&mut self, node: Node) { + self.ungate_all.push(node); + } + + /// Phase 2: release this node from every container gate and queue it + /// under the given policy. + pub(crate) fn queue(&mut self, node: Node, policy: QueuePolicy) { + self.queue.push((node, policy)); + } +} + +impl HugrEngine { + /// Apply a batched container activation with the two-phase discipline: + /// every reset happens before any readiness check. + pub(crate) fn run_activation(&mut self, hugr: &Hugr, act: &ContainerActivation) { + // PHASE 1: clear processed flags first, then stale output wires -- + // readiness checks and value resolution must not see either. + for node in &act.reset_processed { + self.processed.remove(node); + } + for node in &act.reset_wires { + if act.keep_wires.contains(node) { + continue; + } + for port in 0..hugr.num_outputs(*node) { + self.wire_state.classical_values.remove(&(*node, port)); + self.wire_state.wire_to_qubit.remove(&(*node, port)); + self.wire_state.conditional_payloads.remove(&(*node, port)); + } + } + + // Gates: a queued node leaves every container gate (it is being + // activated now); an ungate-only node leaves just the block gate. + for node in &act.ungate_block_only { + self.nodes_inside_cfg_blocks.remove(node); + } + for node in &act.ungate_all { + self.nodes_inside_cfg_blocks.remove(node); + self.nodes_inside_cases.remove(node); + self.nodes_inside_tailloops.remove(node); + } + for (node, _) in &act.queue { + self.nodes_inside_cfg_blocks.remove(node); + self.nodes_inside_cases.remove(node); + self.nodes_inside_tailloops.remove(node); + } + + // PHASE 2: queue, respecting each node's policy. Nodes that fail + // their readiness check here are queued later by + // queue_ready_successors when their producers complete. + for &(node, policy) in &act.queue { + if self.work_queue.contains(node) || self.processed.contains(&node) { + continue; + } + if policy == QueuePolicy::IfReady && !self.all_predecessors_ready(hugr, node) { + continue; + } + self.work_queue.push_back(node); + } + } +} diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index d7b128062..003e682f8 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -35,7 +35,7 @@ use pecos_core::gate_type::GateType; use pecos_quantum::hugr_convert::{ hugr_op_to_gate_type, is_rotation_gate, try_extract_rotation_angle, }; -use tket::hugr::ops::OpType; +use tket::hugr::ops::{OpTrait, OpType}; use tket::hugr::{Hugr, HugrView, Node}; use super::types::{ @@ -216,6 +216,13 @@ pub fn extract_dataflow_block_info( // Find TailLoop nodes inside this block let tailloop_nodes = find_tailloop_nodes_in_block(hugr, node); + // LoadConstant children execute like every other op: block emptiness + // and completion must count them. + let load_constants: BTreeSet = hugr + .children(node) + .filter(|&child| matches!(hugr.get_optype(child), OpType::LoadConstant(_))) + .collect(); + debug!( "DataflowBlock {:?}: {} inputs, {} successors, {} quantum ops, {} calls, {} conditionals, {} bool_ops, {} classical_ops, {} extension_ops, {} tailloops", node, @@ -242,6 +249,7 @@ pub fn extract_dataflow_block_info( classical_ops, extension_ops, tailloop_nodes, + load_constants, input_node, output_node, } @@ -274,8 +282,16 @@ pub fn find_block_successors(hugr: &Hugr, block: Node, num_successors: usize) -> } } - // Convert Option to Node, filtering out None entries - successors.into_iter().flatten().collect() + // Positions are tag indices: compacting out a None would shift every + // later successor onto the wrong tag. Truncate at the first gap instead + // (the downstream out-of-range check then fails loud for tags past it). + let valid = successors.iter().take_while(|s| s.is_some()).count(); + if valid != num_successors { + debug!( + "find_block_successors: block {block:?} has unconnected successor port {valid} of {num_successors}; truncating (tags past it will error loudly)" + ); + } + successors.into_iter().take(valid).flatten().collect() } /// Find all nodes inside CFG blocks (should be deferred until block is active). @@ -336,6 +352,22 @@ pub fn extract_tailloops(hugr: &Hugr) -> BTreeMap { let classical_ops = find_classical_ops_in_block(hugr, node); let bool_ops = find_bool_ops_in_block(hugr, node); let conditional_nodes = find_conditional_nodes_in_block(hugr, node); + // Nested containers: direct TailLoop/CFG children of the body. + // Nothing else queues them once the body is gated, and body + // completion must wait for them. + let mut tailloop_nodes = BTreeSet::new(); + let mut cfg_nodes = BTreeSet::new(); + for child in hugr.children(node) { + match hugr.get_optype(child) { + OpType::TailLoop(_) => { + tailloop_nodes.insert(child); + } + OpType::CFG(_) => { + cfg_nodes.insert(child); + } + _ => {} + } + } debug!( "Found TailLoop node {:?} with {} inputs, {} outputs, {} quantum ops, {} calls, {} extension ops, {} classical ops, {} bool ops, {} conditionals", @@ -365,6 +397,8 @@ pub fn extract_tailloops(hugr: &Hugr) -> BTreeMap { classical_ops, bool_ops, conditional_nodes, + tailloop_nodes, + cfg_nodes, num_inputs, num_outputs, }, @@ -478,11 +512,25 @@ pub fn find_nodes_inside_func_defns( ) -> BTreeSet { let mut inside_func_defns = BTreeSet::new(); - // Find which FuncDefns are called (not the entrypoint) - let called_func_defns: BTreeSet = call_targets.values().copied().collect(); + // Gate EVERY FuncDefn body except the entrypoint's: called functions + // activate through Call/scan frames, and a DEAD (uncalled) function + // must not execute at top level at all -- it would race the real + // program and clobber its return values. Guppy packages carry the + // entrypoint on the HUGR; fall back to the old called-only gating for + // HUGRs whose entrypoint is not a FuncDefn (e.g. module-rooted test + // graphs). + let entrypoint = hugr.entrypoint(); + if func_defns.contains_key(&entrypoint) { + for &func_defn_node in func_defns.keys() { + if func_defn_node != entrypoint { + collect_descendants(hugr, func_defn_node, &mut inside_func_defns); + } + } + return inside_func_defns; + } + let called_func_defns: BTreeSet = call_targets.values().copied().collect(); for &func_defn_node in func_defns.keys() { - // Only defer nodes inside FuncDefns that are called (not the entrypoint) if called_func_defns.contains(&func_defn_node) { collect_descendants(hugr, func_defn_node, &mut inside_func_defns); } @@ -565,117 +613,206 @@ pub fn extract_quantum_ops(hugr: &Hugr) -> BTreeMap { // --- Classical operation extraction --- -/// Extract classical operations from the HUGR (logic, arithmetic, etc.). +/// Classification result: op type, input count, output count, and integer +/// width/signedness where applicable. +pub type ClassicalOpClassification = (ClassicalOpType, usize, usize, Option<(u8, bool)>); + +/// Classify an extension op as a tracked classical operation. /// -/// This identifies operations from extensions like: -/// - `logic`: And, Or, Not, Xor, Eq -/// - `arithmetic.int`: iadd, isub, imul, etc. -/// - `arithmetic.float`: fadd, fsub, fmul, etc. -/// - `arithmetic.conversions`: int/float conversions -/// - `prelude`: `MakeTuple`, `UnpackTuple` +/// Covers `logic`, `arithmetic.int`, `arithmetic.float`, +/// `arithmetic.conversions`, `prelude` tuples, and copyable `Tag` sums. +/// +/// This is THE single source of truth for what counts as a classical op: +/// both the global classical-op map ([`extract_classical_ops`]) and the +/// per-CFG-block classical sets (`collect_classical_ops_recursive`) use it. +/// If the two ever disagree, a node can end up tracked for block completion +/// but never queued for execution (deadlocking the block), or queued but +/// not tracked (letting the block complete before the op ran, silently +/// truncating everything downstream). #[allow(clippy::too_many_lines)] -pub fn extract_classical_ops(hugr: &Hugr) -> BTreeMap { - let mut operations = BTreeMap::new(); - - for node in hugr.nodes() { - let op = hugr.get_optype(node); - - // Check if this is an extension operation - let Some(ext_op) = op.as_extension_op() else { - continue; - }; +pub fn classify_classical_op(op: &OpType) -> Option { + // HUGR `Tag` nodes build tagged sum values (variants, options, branch + // selectors). They execute as classical ops over linear payloads too: + // qubit inputs become ClassicalValue::QubitRef payload elements (the + // executor falls back to the qubit wire map for missing inputs). This + // matters because a Sum value that never materializes starves every + // value-based consumer -- e.g. an iterator's Option result built by a + // Tag over (qubit, state) would leave the caller's match unresolvable. + if let OpType::Tag(_) = op { + let sig = op.dataflow_signature()?; + return Some((ClassicalOpType::TagSum, sig.input_count(), 1, None)); + } - let ext_id = ext_op.extension_id(); - let ext_name = ext_id.as_ref() as &str; - let op_name = ext_op.unqualified_id().to_string(); + // LoadFunction produces a first-class function value for its static + // FuncDefn target (consumed by higher-order ops like scan). + if let OpType::LoadFunction(_) = op { + return Some((ClassicalOpType::LoadFunc, 0, 1, None)); + } - // Map extension operations to ClassicalOpType - let (op_type, num_inputs, num_outputs, int_info) = match ext_name { - // Logic extension - "logic" => match op_name.as_str() { - "And" => (ClassicalOpType::And, 2, 1, None), - "Or" => (ClassicalOpType::Or, 2, 1, None), - "Not" => (ClassicalOpType::Not, 1, 1, None), - "Xor" => (ClassicalOpType::Xor, 2, 1, None), - "Eq" => (ClassicalOpType::Eq, 2, 1, None), - _ => continue, - }, - // Integer arithmetic extension - "arithmetic.int" => { - // Parse operation name to extract signedness info - // Operations like "iadd", "isub" are signed; "iadd_u" are unsigned - let is_signed = !op_name.ends_with("_u"); - match op_name.trim_end_matches("_u").trim_end_matches("_s") { - "iadd" => (ClassicalOpType::Iadd, 2, 1, Some((6, is_signed))), // default 64-bit - "isub" => (ClassicalOpType::Isub, 2, 1, Some((6, is_signed))), - "imul" => (ClassicalOpType::Imul, 2, 1, Some((6, is_signed))), - "idiv" | "idiv_checked" => (ClassicalOpType::Idiv, 2, 1, Some((6, is_signed))), - "imod" => (ClassicalOpType::Imod, 2, 1, Some((6, is_signed))), - "ineg" => (ClassicalOpType::Ineg, 1, 1, Some((6, true))), - "iabs" => (ClassicalOpType::Iabs, 1, 1, Some((6, is_signed))), - "ieq" => (ClassicalOpType::Ieq, 2, 1, Some((6, is_signed))), - "ine" => (ClassicalOpType::Ine, 2, 1, Some((6, is_signed))), - "ilt" => (ClassicalOpType::Ilt, 2, 1, Some((6, is_signed))), - "ile" => (ClassicalOpType::Ile, 2, 1, Some((6, is_signed))), - "igt" => (ClassicalOpType::Igt, 2, 1, Some((6, is_signed))), - "ige" => (ClassicalOpType::Ige, 2, 1, Some((6, is_signed))), - "iand" => (ClassicalOpType::Iand, 2, 1, Some((6, is_signed))), - "ior" => (ClassicalOpType::Ior, 2, 1, Some((6, is_signed))), - "ixor" => (ClassicalOpType::Ixor, 2, 1, Some((6, is_signed))), - "inot" => (ClassicalOpType::Inot, 1, 1, Some((6, is_signed))), - "ishl" => (ClassicalOpType::Ishl, 2, 1, Some((6, is_signed))), - "ishr" => (ClassicalOpType::Ishr, 2, 1, Some((6, is_signed))), - _ => continue, - } + let ext_op = op.as_extension_op()?; + + let ext_id = ext_op.extension_id(); + let ext_name = ext_id.as_ref() as &str; + let op_name = ext_op.unqualified_id().to_string(); + + // Map extension operations to ClassicalOpType + let classified = match ext_name { + // Logic extension + "logic" => match op_name.as_str() { + "And" => (ClassicalOpType::And, 2, 1, None), + "Or" => (ClassicalOpType::Or, 2, 1, None), + "Not" => (ClassicalOpType::Not, 1, 1, None), + "Xor" => (ClassicalOpType::Xor, 2, 1, None), + "Eq" => (ClassicalOpType::Eq, 2, 1, None), + _ => return None, + }, + // Integer arithmetic extension + "arithmetic.int" => { + // Parse operation name to extract signedness info + // Operations like "iadd", "isub" are signed; "iadd_u" are unsigned + let is_signed = !op_name.ends_with("_u"); + // The op's width comes from its BoundedNat type arg + // (log_width: int has 2^N bits); absent args mean the + // generic default of 64-bit. + let lw = ext_op + .args() + .iter() + .find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => u8::try_from(*n).ok(), + _ => None, + }) + .unwrap_or(6); + match op_name.trim_end_matches("_u").trim_end_matches("_s") { + "iadd" => (ClassicalOpType::Iadd, 2, 1, Some((lw, is_signed))), // default 64-bit + "isub" => (ClassicalOpType::Isub, 2, 1, Some((lw, is_signed))), + "imul" => (ClassicalOpType::Imul, 2, 1, Some((lw, is_signed))), + "idiv" => (ClassicalOpType::Idiv, 2, 1, Some((lw, is_signed))), + "idiv_checked" => (ClassicalOpType::IdivChecked, 2, 1, Some((lw, is_signed))), + "imod" => (ClassicalOpType::Imod, 2, 1, Some((lw, is_signed))), + "imod_checked" => (ClassicalOpType::ImodChecked, 2, 1, Some((lw, is_signed))), + "idivmod" => (ClassicalOpType::Idivmod, 2, 2, Some((lw, is_signed))), + "idivmod_checked" => (ClassicalOpType::IdivmodChecked, 2, 1, Some((lw, is_signed))), + "ipow" => (ClassicalOpType::Ipow, 2, 1, Some((lw, is_signed))), + "ineg" => (ClassicalOpType::Ineg, 1, 1, Some((lw, true))), + "iabs" => (ClassicalOpType::Iabs, 1, 1, Some((lw, is_signed))), + "ieq" => (ClassicalOpType::Ieq, 2, 1, Some((lw, is_signed))), + "ine" => (ClassicalOpType::Ine, 2, 1, Some((lw, is_signed))), + "ilt" => (ClassicalOpType::Ilt, 2, 1, Some((lw, is_signed))), + "ile" => (ClassicalOpType::Ile, 2, 1, Some((lw, is_signed))), + "igt" => (ClassicalOpType::Igt, 2, 1, Some((lw, is_signed))), + "ige" => (ClassicalOpType::Ige, 2, 1, Some((lw, is_signed))), + "iand" => (ClassicalOpType::Iand, 2, 1, Some((lw, is_signed))), + "ior" => (ClassicalOpType::Ior, 2, 1, Some((lw, is_signed))), + "ixor" => (ClassicalOpType::Ixor, 2, 1, Some((lw, is_signed))), + "inot" => (ClassicalOpType::Inot, 1, 1, Some((lw, is_signed))), + "ishl" => (ClassicalOpType::Ishl, 2, 1, Some((lw, is_signed))), + "ishr" => (ClassicalOpType::Ishr, 2, 1, Some((lw, is_signed))), + _ => return None, } - // Float arithmetic extension - "arithmetic.float" => match op_name.as_str() { - "fadd" => (ClassicalOpType::Fadd, 2, 1, None), - "fsub" => (ClassicalOpType::Fsub, 2, 1, None), - "fmul" => (ClassicalOpType::Fmul, 2, 1, None), - "fdiv" => (ClassicalOpType::Fdiv, 2, 1, None), - "fneg" => (ClassicalOpType::Fneg, 1, 1, None), - "fabs" => (ClassicalOpType::Fabs, 1, 1, None), - "ffloor" => (ClassicalOpType::Ffloor, 1, 1, None), - "fceil" => (ClassicalOpType::Fceil, 1, 1, None), - "feq" => (ClassicalOpType::Feq, 2, 1, None), - "fne" => (ClassicalOpType::Fne, 2, 1, None), - "flt" => (ClassicalOpType::Flt, 2, 1, None), - "fle" => (ClassicalOpType::Fle, 2, 1, None), - "fgt" => (ClassicalOpType::Fgt, 2, 1, None), - "fge" => (ClassicalOpType::Fge, 2, 1, None), - _ => continue, - }, - // Conversion extension - "arithmetic.conversions" => match op_name.as_str() { - "convert_s" | "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, None), - "trunc_s" | "trunc_u" => (ClassicalOpType::ConvertFloatToInt, 1, 1, None), - _ => continue, - }, - // Prelude extension (tuples, etc.) - "prelude" => { - let num_inputs = hugr.num_inputs(node); - let num_outputs = hugr.num_outputs(node); - match op_name.as_str() { - "MakeTuple" => (ClassicalOpType::MakeTuple, num_inputs, 1, None), - "UnpackTuple" => (ClassicalOpType::UnpackTuple, 1, num_outputs, None), - _ => continue, - } + } + // Float arithmetic extension + "arithmetic.float" => match op_name.as_str() { + "fadd" => (ClassicalOpType::Fadd, 2, 1, None), + "fsub" => (ClassicalOpType::Fsub, 2, 1, None), + "fmul" => (ClassicalOpType::Fmul, 2, 1, None), + "fdiv" => (ClassicalOpType::Fdiv, 2, 1, None), + "fneg" => (ClassicalOpType::Fneg, 1, 1, None), + "fabs" => (ClassicalOpType::Fabs, 1, 1, None), + "ffloor" => (ClassicalOpType::Ffloor, 1, 1, None), + "fceil" => (ClassicalOpType::Fceil, 1, 1, None), + "feq" => (ClassicalOpType::Feq, 2, 1, None), + "fne" => (ClassicalOpType::Fne, 2, 1, None), + "flt" => (ClassicalOpType::Flt, 2, 1, None), + "fle" => (ClassicalOpType::Fle, 2, 1, None), + "fgt" => (ClassicalOpType::Fgt, 2, 1, None), + "fge" => (ClassicalOpType::Fge, 2, 1, None), + _ => return None, + }, + // Conversion extension + "arithmetic.conversions" => { + let lw = ext_op + .args() + .iter() + .find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => u8::try_from(*n).ok(), + _ => None, + }) + .unwrap_or(6); + match op_name.as_str() { + "convert_s" => (ClassicalOpType::ConvertIntToFloat, 1, 1, Some((lw, true))), + "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, Some((lw, false))), + // trunc_s/trunc_u return sum_with_error(int), not a raw int + "trunc_s" => ( + ClassicalOpType::ConvertFloatToIntChecked, + 1, + 1, + Some((lw, true)), + ), + "trunc_u" => ( + ClassicalOpType::ConvertFloatToIntChecked, + 1, + 1, + Some((lw, false)), + ), + "itobool" => (ClassicalOpType::ItoBool, 1, 1, Some((0, false))), + "ifrombool" => (ClassicalOpType::IfromBool, 1, 1, Some((0, false))), + _ => return None, } - _ => continue, - }; + } + // Prelude extension (tuples, etc.) + "prelude" => { + // Use the dataflow-signature port counts, NOT hugr.num_inputs/ + // num_outputs: the portgraph counts include the order port, and + // an inflated num_inputs makes handle_classical_op wait forever + // on an order-port "value" that never arrives (starving every + // consumer downstream of the tuple). + let sig = op.dataflow_signature()?; + // Tuples that carry LINEAR values (qubits) are wire routing, + // not classical computation: the classical executor can never + // produce values for them (they would defer forever and block + // CFG-block completion). Qubit flow through them is resolved + // structurally by wire tracing instead. + if sig + .input() + .iter() + .chain(sig.output().iter()) + .any(|t| !t.copyable()) + { + return None; + } + let num_inputs = sig.input_count(); + let num_outputs = sig.output_count(); + match op_name.as_str() { + "MakeTuple" => (ClassicalOpType::MakeTuple, num_inputs, 1, None), + "UnpackTuple" => (ClassicalOpType::UnpackTuple, 1, num_outputs, None), + _ => return None, + } + } + _ => return None, + }; - operations.insert( - node, - ClassicalOp { + Some(classified) +} + +/// Extract classical operations from the HUGR (logic, arithmetic, tuples). +pub fn extract_classical_ops(hugr: &Hugr) -> BTreeMap { + let mut operations = BTreeMap::new(); + + for node in hugr.nodes() { + let op = hugr.get_optype(node); + if let Some((op_type, num_inputs, num_outputs, int_info)) = classify_classical_op(op) { + operations.insert( node, - op_type, - num_inputs, - num_outputs, - int_info, - const_value: None, - }, - ); + ClassicalOp { + node, + op_type, + num_inputs, + num_outputs, + int_info, + const_value: None, + }, + ); + } } operations @@ -690,11 +827,27 @@ pub fn find_quantum_ops_in_block(hugr: &Hugr, block: Node) -> BTreeSet { ops } -/// Recursively collect quantum operations in a subtree. +/// Recursively collect quantum operations in a subtree, stopping at nested +/// control-flow containers. +/// +/// Ops inside a nested Conditional/TailLoop/CFG/FuncDefn belong to THAT +/// container's activation: collecting them here queues them when the outer +/// block activates -- before the case is selected or the loop iterates -- +/// which executes unselected-branch gates (observed as phantom extra +/// allocations and measurements). Completion tracking covers the nested +/// work through the container node itself (conditionals gate on +/// no-active-case; Calls/TailLoops are marked processed only at completion). fn collect_quantum_ops_recursive(hugr: &Hugr, node: Node, ops: &mut BTreeSet) { for child in hugr.children(node) { let op = hugr.get_optype(child); + if matches!( + op, + OpType::Conditional(_) | OpType::TailLoop(_) | OpType::CFG(_) | OpType::FuncDefn(_) + ) { + continue; + } + // Check if this is a quantum extension operation if let Some(ext_op) = op.as_extension_op() { let ext_id = ext_op.extension_id(); @@ -705,7 +858,8 @@ fn collect_quantum_ops_recursive(hugr: &Hugr, node: Node, ops: &mut BTreeSet BTreeSet { } /// Recursively collect classical operation nodes in a subtree. +/// +/// Uses [`classify_classical_op`] so the per-block classical sets match the +/// global classical-op map exactly (see that function's docs for why a +/// mismatch deadlocks or silently truncates CFG-block execution). fn collect_classical_ops_recursive(hugr: &Hugr, node: Node, classical_ops: &mut BTreeSet) { for child in hugr.children(node) { let op = hugr.get_optype(child); - if let Some(ext_op) = op.as_extension_op() { - let ext_id = ext_op.extension_id(); - let ext_name = ext_id.as_ref() as &str; - // Classical ops are from these extensions - if matches!( - ext_name, - "logic" | "arithmetic.int" | "arithmetic.float" | "arithmetic.conversions" - ) { - classical_ops.insert(child); - } - // Also check prelude for MakeTuple/UnpackTuple - if ext_name == "prelude" { - let op_name = ext_op.unqualified_id().to_string(); - if op_name == "MakeTuple" || op_name == "UnpackTuple" { - classical_ops.insert(child); - } - } + if classify_classical_op(op).is_some() { + classical_ops.insert(child); } // Recurse into nested containers (but not into FuncDefns or Conditionals) if !matches!(op, OpType::FuncDefn(_) | OpType::Conditional(_)) { @@ -904,38 +1053,6 @@ pub fn get_container_type(hugr: &Hugr, node: Node) -> ContainerType { } } -/// Check if all quantum predecessors of a node have been processed. -/// This includes quantum operations, Conditionals, CFGs, `TailLoops`, and Call nodes. -pub fn all_predecessors_ready( - hugr: &Hugr, - node: Node, - quantum_ops: &BTreeMap, - conditionals: &BTreeMap, - cfgs: &BTreeMap, - processed: &BTreeSet, -) -> bool { - for pred_node in hugr.input_neighbours(node) { - // Check quantum ops - if quantum_ops.contains_key(&pred_node) && !processed.contains(&pred_node) { - return false; - } - // Check conditionals (they also produce qubit outputs) - if conditionals.contains_key(&pred_node) && !processed.contains(&pred_node) { - return false; - } - // Check CFG nodes (they also produce qubit outputs) - if cfgs.contains_key(&pred_node) && !processed.contains(&pred_node) { - return false; - } - // Check Call nodes and TailLoop nodes (they also produce qubit/array outputs) - let op = hugr.get_optype(pred_node); - if matches!(op, OpType::Call(_) | OpType::TailLoop(_)) && !processed.contains(&pred_node) { - return false; - } - } - true -} - #[cfg(test)] mod tests { diff --git a/crates/pecos-hugr/src/engine/control_flow.rs b/crates/pecos-hugr/src/engine/control_flow.rs index da0ededbc..253377452 100644 --- a/crates/pecos-hugr/src/engine/control_flow.rs +++ b/crates/pecos-hugr/src/engine/control_flow.rs @@ -27,4 +27,5 @@ pub mod call; pub mod cfg; pub mod conditional; +pub mod scan; pub mod tailloop; diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index 25053a3ba..e7e9beb14 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -33,12 +33,110 @@ //! 6. Call's successors are added to work queue use log::debug; -use tket::hugr::{Hugr, HugrView, IncomingPort, PortIndex}; +use tket::hugr::ops::OpType; +use tket::hugr::types::TypeArg; +use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; -use crate::engine::analysis::all_predecessors_ready; impl HugrEngine { + /// Fill-only repair of active calls' argument ports. + /// + /// A Call launches as soon as it is dispatched; an argument that is a + /// measurement result still in flight leaves its `FuncDefn` Input port + /// cleared. Called after each measurement round: writes only ports that + /// are currently MISSING (never overwrites live frame state), reading + /// through the tracing layer. + pub(crate) fn repropagate_active_call_inputs(&mut self, hugr: &Hugr) { + let targets: Vec<(Node, Node, usize)> = self + .active_calls + .iter() + .filter_map(|(&call_node, info)| { + self.func_defns + .get(&info.func_defn_node) + .map(|fi| (call_node, fi.input_node, fi.num_inputs)) + }) + .collect(); + for (call_node, input_node, num_inputs) in targets { + for port in 0..num_inputs { + let wire = (input_node, port); + if self.wire_state.classical_values.contains_key(&wire) + || self.wire_state.wire_to_qubit.contains_key(&wire) + { + continue; + } + if let Some(qubit_id) = self.get_input_qubit(hugr, call_node, port) { + self.wire_state.wire_to_qubit.insert(wire, qubit_id); + } + if let Some(value) = self.get_input_value(hugr, call_node, port) { + debug!( + "Call {call_node:?}: late argument {port} repaired with {value:?} on {wire:?}" + ); + self.wire_state.classical_values.insert(wire, value); + } + } + } + } + + /// Resolve a type variable used inside a called function body to the + /// concrete `BoundedNat` from the calling `Call`'s instantiation args. + /// + /// Generic function bodies reference their type parameters as variables + /// (e.g. `prelude.load_nat` of a generic loop bound); only the calling + /// `Call` op knows the concrete instantiation. + pub(crate) fn resolve_call_type_arg( + &self, + hugr: &Hugr, + node: Node, + var_idx: usize, + ) -> Option { + let mut node = node; + let mut var_idx = var_idx; + // A generic function may be called from another generic function + // with the type arg forwarded as a variable (`f<$0>` inside `g`), + // so resolution walks the active call chain until a concrete + // BoundedNat appears. Bounded by the active-call count: each hop + // consumes one distinct call frame. + for _ in 0..=self.active_calls.len() { + // Find the enclosing FuncDefn of this node. + let mut cur = hugr.get_parent(node); + let func_defn = loop { + let n = cur?; + if matches!(hugr.get_optype(n), OpType::FuncDefn(_)) { + break n; + } + cur = hugr.get_parent(n); + }; + // Find the active call OR scan executing this FuncDefn and + // read its arg (a scanned function's frame carries the + // LoadFunction's instantiation args). + let (owner_node, arg) = if let Some(info) = self + .active_calls + .values() + .find(|info| info.func_defn_node == func_defn) + { + (info.call_node, info.type_args.get(var_idx)?.clone()) + } else { + let scan = self + .active_scans + .values() + .find(|scan| scan.func_defn_node == func_defn)?; + (scan.scan_node, scan.type_args.get(var_idx)?.clone()) + }; + match arg { + TypeArg::BoundedNat(n) => return Some(n), + TypeArg::Variable(var) => { + // Forwarded generic: continue resolution in the CALLER's + // frame, at the caller's variable index. + node = owner_node; + var_idx = var.index(); + } + _ => return None, + } + } + None + } + /// Complete a function call if the completed CFG belongs to an active Call's `FuncDefn`. /// /// This method is called when a CFG completes. It checks if that CFG belongs @@ -48,6 +146,11 @@ impl HugrEngine { /// 3. Adds Call successors to the work queue /// 4. Starts any pending calls to the same `FuncDefn` pub(crate) fn complete_func_call_if_needed(&mut self, hugr: &Hugr, cfg_node: tket::hugr::Node) { + // A scan folding through this FuncDefn owns the frame: route the + // completion to it (next element, or scan completion). + if self.continue_scan_after_frame(hugr, cfg_node) { + return; + } // Find which active Call (if any) has a FuncDefn with this CFG let call_to_complete: Option<(tket::hugr::Node, tket::hugr::Node)> = self .active_calls @@ -73,33 +176,26 @@ impl HugrEngine { for port in 0..func_info.num_outputs { // Check if we have a wire mapping for the FuncDefn Output input // FuncDefn Output receives from CFG outputs - let output_in_port = IncomingPort::from(port); - if let Some((src_node, src_port)) = - hugr.single_linked_output(func_info.output_node, output_in_port) + // Traced reads: a return value produced inside a + // flattened DFG must resolve like any other read. + let call_output_wire = (call_node, port); + if let Some(qubit_id) = self.get_input_qubit(hugr, func_info.output_node, port) { - let src_wire = (src_node, src_port.index()); - // Map qubits - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - let call_output_wire = (call_node, port); - self.wire_state - .wire_to_qubit - .insert(call_output_wire, qubit_id); - debug!( - "Call {call_node:?}: mapped FuncDefn output {port} qubit {qubit_id:?} to Call output" - ); - } - // Map classical values (including arrays) - if let Some(value) = - self.wire_state.classical_values.get(&src_wire).cloned() - { - let call_output_wire = (call_node, port); - self.wire_state - .classical_values - .insert(call_output_wire, value.clone()); - debug!( - "Call {call_node:?}: mapped FuncDefn output {port} classical value {value:?} to Call output" - ); - } + self.wire_state + .wire_to_qubit + .insert(call_output_wire, qubit_id); + debug!( + "Call {call_node:?}: mapped FuncDefn output {port} qubit {qubit_id:?} to Call output" + ); + } + // Map classical values (including arrays) + if let Some(value) = self.get_input_value(hugr, func_info.output_node, port) { + debug!( + "Call {call_node:?}: mapped FuncDefn output {port} classical value {value:?} to Call output" + ); + self.wire_state + .classical_values + .insert(call_output_wire, value); } } @@ -107,6 +203,12 @@ impl HugrEngine { self.processed.insert(call_node); self.active_calls.remove(&call_node); + // Check if this Call completion allows a parent Case to + // complete (a case whose FINAL completion event is the Call + // itself would otherwise stay active forever). + self.check_scan_frame_completion(hugr, call_node); + self.check_case_completion(hugr, call_node); + // Check if this Call completion allows a parent CFG block to complete // This is critical for nested function calls self.check_cfg_block_completion(hugr, call_node); @@ -115,38 +217,27 @@ impl HugrEngine { // This is critical for function calls inside TailLoop bodies self.check_tailloop_body_completion(hugr, call_node); - // Add Call's successors to work queue - for succ_node in hugr.output_neighbours(call_node) { - if (self.quantum_ops.contains_key(&succ_node) - || self.call_targets.contains_key(&succ_node) - || self.conditionals.contains_key(&succ_node) - || self.cfgs.contains_key(&succ_node)) - && !self.processed.contains(&succ_node) - && !self.work_queue.contains(&succ_node) - && all_predecessors_ready( - hugr, - succ_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - debug!("Call {call_node:?}: adding successor {succ_node:?} to work queue"); - self.work_queue.push_back(succ_node); - } - } + // Add Call's successors to the work queue via the canonical + // readiness check. (A hand-rolled subset here used to omit + // classical and extension ops, so e.g. a MakeTuple consuming + // the Call's result never ran and the enclosing CFG block + // never completed.) + self.queue_ready_successors(hugr, call_node); + + // A scan parked on this frame retries through the pending + // mechanism -- wake the parked set now that the frame freed. + self.retry_deferred_nodes(); // Check if there are pending calls to this FuncDefn if let Some(pending) = self.pending_func_calls.get_mut(&func_defn_node) - && let Some(next_call) = pending.pop() + && let Some(next_call) = pending.pop_front() { debug!( "FuncDefn {func_defn_node:?} free: starting next pending Call {next_call:?}" ); // Add the pending call to the front of the work queue // so it gets processed next - if !self.work_queue.contains(&next_call) { + if !self.work_queue.contains(next_call) { self.work_queue.push_front(next_call); } } diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index b0930ee2c..b5c77dde5 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -39,12 +39,15 @@ use tket::hugr::ops::OpType; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; -use crate::engine::analysis::{ - all_predecessors_ready, find_extension_ops_in_block, find_input_node, find_output_node, -}; +use crate::engine::activation::{ContainerActivation, QueuePolicy}; +use crate::engine::analysis::{find_extension_ops_in_block, find_input_node, find_output_node}; use crate::engine::types::ClassicalValue; impl HugrEngine { + /// Block-transition ceiling for CFGs (loop-iteration bound: guppy loops + /// are CFG cycles; a loop that never breaks must error, not hang). + const MAX_CFG_TRANSITIONS: u64 = 10_000_000; + /// Try to resolve the branch value for a CFG `DataflowBlock`. /// Returns `Some(branch_index)` if the Sum tag value is known, None otherwise. #[allow(clippy::too_many_lines)] @@ -84,15 +87,16 @@ impl HugrEngine { hugr.single_linked_output(src_node, tag_input_port) { let tag_src_wire = (tag_src_node, tag_src_port.index()); - if let Some(input_value) = self.wire_state.classical_values.get(&tag_src_wire) - && let Some(v) = input_value.to_u32() - { + if self.wire_state.classical_values.contains_key(&tag_src_wire) { + // A Tag node ALWAYS produces its own variant: the + // branch is the tag, not the wrapped value. (The + // executed-value path above returns the Sum's tag + // for exactly the same reason; this structural + // fallback must agree with it.) debug!( - "CFG block {block_node:?} resolved via Tag: tag={tag_value}, input={v}" + "CFG block {block_node:?} resolved via Tag with known input: tag={tag_value}" ); - // For booleans converted to Sum: input_value determines the branch - // The Tag wraps the value - we use the input value as the branch - return Some(v as usize); + return Some(tag_value); } } @@ -135,78 +139,12 @@ impl HugrEngine { } } - // Check if the source is a Conditional node (inside the block) - // The Conditional's output is a Sum type - we need to trace its control input - if matches!(src_op, OpType::Conditional(_)) { - debug!( - "[TRACE] Block {block_node:?} output from Conditional {src_node:?}, tracing control input" - ); - // Conditional's control input is port 0 - let control_port = IncomingPort::from(0); - if let Some((ctrl_src_node, ctrl_src_port)) = - hugr.single_linked_output(src_node, control_port) - { - // The control input might be from tket.bool.read - let ctrl_op = hugr.get_optype(ctrl_src_node); - if let Some(ext_op) = ctrl_op.as_extension_op() { - let ext_id = ext_op.extension_id(); - let op_name = ext_op.unqualified_id(); - if ext_id.as_ref() as &str == "tket.bool" && op_name == "read" { - // Trace the bool input to tket.bool.read - let bool_input_port = IncomingPort::from(0); - if let Some((bool_src_node, bool_src_port)) = - hugr.single_linked_output(ctrl_src_node, bool_input_port) - { - let bool_wire = (bool_src_node, bool_src_port.index()); - debug!( - "[TRACE] tket.bool.read input comes from {bool_wire:?}, checking classical_values" - ); - - // First check if we have a classical value for this wire - if let Some(bool_value) = - self.wire_state.classical_values.get(&bool_wire) - && let Some(v) = bool_value.to_u32() - { - debug!( - "[TRACE] Found classical value {v} for Conditional control" - ); - // The bool value (0 or 1) determines which Case - // Case 0 = false, Case 1 = true - // Each Case outputs a Tag that determines the successor - // For while loop: false -> Case 0 -> Tag 0 -> continue - // true -> Case 1 -> Tag 1 -> exit - return Some(v as usize); - } - - // Try to resolve constant bool - if let Some(const_value) = - Self::try_resolve_const_bool(hugr, bool_src_node) - { - debug!( - "CFG block {block_node:?} Conditional control resolved from const: {const_value}" - ); - return Some(usize::from(const_value)); - } - - debug!( - "[TRACE] Could not resolve bool value for wire {bool_wire:?}" - ); - } - } - } - - // Check classical_values for the control wire - let ctrl_wire = (ctrl_src_node, ctrl_src_port.index()); - if let Some(ctrl_value) = self.wire_state.classical_values.get(&ctrl_wire) - && let Some(v) = ctrl_value.to_u32() - { - debug!( - "CFG block {block_node:?} Conditional control from classical value: {v}" - ); - return Some(v as usize); - } - } - } + // A branch Sum produced by a Conditional resolves through the + // VALUE path above once the selected case completes and + // propagates its outputs -- the case's branch Tag need not equal + // the condition, so shortcutting to the Conditional's CONTROL + // value here routed to the wrong successor. Wait for the real + // Sum instead. } None @@ -263,6 +201,9 @@ impl HugrEngine { /// Try to resolve pending CFG blocks that were waiting for measurement results. pub(crate) fn try_resolve_pending_cfg_branches(&mut self) { + if self.pending_cfg_branches.is_empty() { + return; + } let hugr = match &self.hugr { Some(h) => h.clone(), None => return, @@ -296,12 +237,13 @@ impl HugrEngine { ); self.transition_to_cfg_successor(&hugr, cfg_node, block_node, next_block); } else { - debug!( - "[TRACE] Resolving pending: {block_node:?} branch {branch_idx} out of range, using first" - ); - if !successors.is_empty() { - self.transition_to_cfg_successor(&hugr, cfg_node, block_node, successors[0]); - } + // Out-of-range tag = upstream Sum/tag propagation bug; + // taking an arbitrary successor would mask it. + self.execution_error = Some(format!( + "CFG {cfg_node:?} block {block_node:?}: pending branch tag {branch_idx} \ + out of range ({} successors)", + successors.len() + )); } } } @@ -320,71 +262,37 @@ impl HugrEngine { // Check the current block if let Some(block_info) = cfg_info.blocks.get(&active_cfg.current_block) { - // Check if this block has operations that drive completion. - // Quantum, calls, conditionals, bool, extension, and tailloops - // complete explicitly. Classical_ops complete when their inputs are ready. - let has_completion_driving_ops = !block_info.quantum_ops.is_empty() - || !block_info.call_nodes.is_empty() - || !block_info.conditional_nodes.is_empty() - || !block_info.bool_ops.is_empty() - || !block_info.extension_ops.is_empty() - || !block_info.tailloop_nodes.is_empty(); - - // Check if the processed node is in this block - let is_in_block = if has_completion_driving_ops { - block_info.quantum_ops.contains(&processed_node) - || block_info.call_nodes.contains(&processed_node) - || block_info.conditional_nodes.contains(&processed_node) - || block_info.bool_ops.contains(&processed_node) - || block_info.extension_ops.contains(&processed_node) - || block_info.tailloop_nodes.contains(&processed_node) - } else { - // Block has only classical ops - track those for completion - block_info.classical_ops.contains(&processed_node) - }; + // A block is complete only when EVERY tracked op set in it has + // been processed -- including classical ops. Transitioning + // while a classical op is still pending would propagate + // missing block outputs (e.g. a partially-built loop-state + // tuple) and silently starve everything downstream. + let is_in_block = block_info.quantum_ops.contains(&processed_node) + || block_info.call_nodes.contains(&processed_node) + || block_info.conditional_nodes.contains(&processed_node) + || block_info.bool_ops.contains(&processed_node) + || block_info.extension_ops.contains(&processed_node) + || block_info.tailloop_nodes.contains(&processed_node) + || block_info.classical_ops.contains(&processed_node) + || block_info.load_constants.contains(&processed_node); if is_in_block { - // Check completion based on block type - let block_complete = if has_completion_driving_ops { - // Block with completion-driving ops: wait for all such op types. - let all_quantum_done = block_info - .quantum_ops - .iter() - .all(|op| self.processed.contains(op)); - let all_calls_done = block_info - .call_nodes - .iter() - .all(|call| self.processed.contains(call)); - let all_conditionals_done = block_info - .conditional_nodes - .iter() - .all(|cond| self.processed.contains(cond)); - let all_bools_done = block_info - .bool_ops - .iter() - .all(|op| self.processed.contains(op)); - let all_extensions_done = block_info - .extension_ops - .iter() - .all(|op| self.processed.contains(op)); - let all_tailloops_done = block_info - .tailloop_nodes - .iter() - .all(|tl| self.processed.contains(tl)); - - all_quantum_done - && all_calls_done - && all_conditionals_done - && all_bools_done - && all_extensions_done - && all_tailloops_done - } else { - // Classical-only block: wait for classical ops - block_info - .classical_ops - .iter() - .all(|op| self.processed.contains(op)) + // node_settled, not bare `processed`: several container + // ops are marked processed while their results are still + // in flight (a Conditional at EXPANSION, a Call/TailLoop + // while its frame is active) -- transitioning then would + // copy missing values. + let all_done = |set: &std::collections::BTreeSet| { + set.iter().all(|op| self.node_settled(*op)) }; + let block_complete = all_done(&block_info.quantum_ops) + && all_done(&block_info.call_nodes) + && all_done(&block_info.conditional_nodes) + && all_done(&block_info.bool_ops) + && all_done(&block_info.extension_ops) + && all_done(&block_info.tailloop_nodes) + && all_done(&block_info.classical_ops) + && all_done(&block_info.load_constants); if block_complete { block_completions.push(( @@ -433,19 +341,14 @@ impl HugrEngine { next_block, ); } else { - debug!( - "CFG {:?} block {:?}: branch {} out of range ({}), defaulting to first", - cfg_node, - completed_block, - branch_idx, + // An out-of-range tag means a Sum/tag propagation + // bug upstream; routing to an arbitrary successor + // would mask it as plausible control flow. + self.execution_error = Some(format!( + "CFG {cfg_node:?} block {completed_block:?}: branch tag {branch_idx} \ + out of range ({} successors)", successors.len() - ); - self.transition_to_cfg_successor( - hugr, - cfg_node, - completed_block, - successors[0], - ); + )); } } else { // Branch value not yet known - store as pending @@ -473,378 +376,286 @@ impl HugrEngine { return; }; - // If to_block is the ExitBlock, complete the CFG. - // ExitBlock has no operations - it's just a marker node. The from_block (a DataflowBlock) - // should have already executed any result operations before this transition. - if to_block == cfg_info.exit_block { - debug!("CFG {cfg_node:?}: transitioning to exit block {to_block:?}"); - self.complete_cfg_execution(hugr, cfg_node, from_block); - return; - } - - debug!("CFG {cfg_node:?}: transitioning from block {from_block:?} to {to_block:?}"); - - // Propagate wire mappings from completed block to successor block - self.propagate_block_outputs_to_successor(hugr, from_block, to_block); + // ITERATIVE transition: a chain of empty blocks re-enters this + // logic once per hop. Recursing here overflowed the stack around + // ~10^5 hops -- an abort, not the clean MAX_CFG_TRANSITIONS error. + // Every hop inside this invocation shares one cascade id. + self.cfg_transition_cascade += 1; + let cascade = self.cfg_transition_cascade; + let mut from_block = from_block; + let mut to_block = to_block; + 'transition: loop { + // If to_block is the ExitBlock, complete the CFG. + // ExitBlock has no operations - it's just a marker node. The from_block (a DataflowBlock) + // should have already executed any result operations before this transition. + if to_block == cfg_info.exit_block { + debug!("CFG {cfg_node:?}: transitioning to exit block {to_block:?}"); + self.complete_cfg_execution(hugr, cfg_node, from_block); + return; + } - // Record this propagation for re-propagation after measurement results - // are available (measurement results may not be stored yet when we transition) - self.pending_measurement_propagations - .push((cfg_node, from_block, to_block)); + debug!("CFG {cfg_node:?}: transitioning from block {from_block:?} to {to_block:?}"); - // Update active CFG state - if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { - active_cfg.completed_blocks.insert(from_block); - active_cfg.current_block = to_block; - } + // Propagate wire mappings from completed block to successor block + self.propagate_block_outputs_to_successor(hugr, from_block, to_block); - // Activate successor block's quantum ops and Call nodes - if let Some(block_info) = cfg_info.blocks.get(&to_block) { - // Clear stale classical values for all operations in this block. - // This is critical for loops: without this, nodes like tket.bool.read - // retain values from the previous iteration. Since Conditionals are - // added to the work queue before bool_ops, the Conditional would read - // the stale value and select the wrong branch. - let block_input_node = find_input_node(hugr, to_block); - for child in hugr.children(to_block) { - // Don't clear the Input node - it has fresh values from propagation - if Some(child) == block_input_node { - continue; - } - let num_outputs = hugr.num_outputs(child); - for port_idx in 0..num_outputs { - self.wire_state.classical_values.remove(&(child, port_idx)); + // Record this propagation for re-propagation after measurement results + // are available (measurement results may not be stored yet when we + // transition). Retention is PURGE-ON-REACTIVATION: entering + // to_block supersedes any previously recorded edge INTO it (the + // previous loop iteration's), while edges into other blocks of + // the same chain survive -- a multi-hop transition through an + // empty block records EVERY hop, so replay can walk the chain + // and carry a late measurement value across it. (The old + // latest-edge-only policy amputated chains: the A->empty hop + // was dropped and the value never reached the consumer.) + // A revisit WITHIN this cascade must not purge the older hop + // into the same block (that hop is part of the chain a late + // value walks); a re-entry in a LATER cascade supersedes it. + self.pending_measurement_propagations + .retain(|(cfg, _, to, c)| *cfg != cfg_node || *to != to_block || *c == cascade); + self.pending_measurement_propagations + .push((cfg_node, from_block, to_block, cascade)); + + // Update active CFG state. Guppy loops lower to CFG cycles, so the + // transition count doubles as the loop-iteration ceiling: a + // never-breaking classical loop would otherwise spin the processing + // loop forever with no yield. + if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { + active_cfg.completed_blocks.insert(from_block); + active_cfg.current_block = to_block; + active_cfg.transitions += 1; + if active_cfg.transitions > Self::MAX_CFG_TRANSITIONS { + self.execution_error = Some(format!( + "CFG {cfg_node:?} exceeded {} block transitions without exiting", + Self::MAX_CFG_TRANSITIONS + )); + return; } } - // Clear processed state for quantum ops first so they can be re-executed in loops - for &op_node in &block_info.quantum_ops { - self.processed.remove(&op_node); - } - for &op_node in &block_info.quantum_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip ops inside TailLoops - they'll be added when the loop expands - if self.nodes_inside_tailloops.contains(&op_node) { - continue; + // Activate successor block's quantum ops and Call nodes + let Some(block_info) = cfg_info.blocks.get(&to_block) else { + // A successor that is not a known DataflowBlock (invalid + // HUGR, or a stale pending branch racing CFG completion) + // must fault: falling through here used to re-run the loop + // with unchanged state -- a 10M-hop spin at best, unbounded + // if the active entry was already gone. + self.execution_error = Some(format!( + "CFG {cfg_node:?}: successor {to_block:?} is not a known DataflowBlock" + )); + return; + }; + { + self.executed_containers.insert(to_block, "DataflowBlock"); + // Re-activate the block through the shared two-phase mechanism. + // Stale wires clear for every child except the Input node (it + // holds fresh values from propagation): without this, nodes + // like tket.bool.read retain values from the previous loop + // iteration and a Conditional queued before the bool op reads + // the stale value and selects the wrong branch. Processed flags + // clear for EVERY op category before ANY readiness check: + // interleaving clear-and-queue per category let a Call pass + // readiness against the PREVIOUS iteration's flags of a + // not-yet-cleared producer and re-execute the same iteration + // forever. Ops inside TailLoops leave the block gate but queue + // only when their loop expands. + let block_input_node = find_input_node(hugr, to_block); + let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); + + // DFG interiors FLATTEN into the global op maps, so no + // container machinery owns their re-activation -- without + // resetting them here, an interior op (e.g. a Tag over + // loop-carried values) keeps its previous-iteration wire + // forever, and the tracing layer serves that frozen value + // SILENTLY. Collect them and treat them like direct + // children. (Nested Conditionals/TailLoops own their own + // frames; only DFG chains are descended.) + let mut dfg_interior: Vec = Vec::new(); + let mut dfg_stack: Vec = hugr + .children(to_block) + .filter(|c| matches!(hugr.get_optype(*c), OpType::DFG(_))) + .collect(); + while let Some(dfg) = dfg_stack.pop() { + for child in hugr.children(dfg) { + dfg_interior.push(child); + if matches!(hugr.get_optype(child), OpType::DFG(_)) { + dfg_stack.push(child); + } + } + } + + let mut act = ContainerActivation::new(); + for child in hugr.children(to_block) { + act.reset_wires(child); } - if !self.work_queue.contains(&op_node) && !self.processed.contains(&op_node) { - self.work_queue.push_back(op_node); + for &child in &dfg_interior { + act.reset_wires(child); } - } - // Also activate Call nodes in this block - for &call_node in &block_info.call_nodes { - self.nodes_inside_cfg_blocks.remove(&call_node); - // Skip Call nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&call_node) { - continue; + if let Some(input) = block_input_node { + act.keep_wires(input); } - if !self.work_queue.contains(&call_node) - && !self.processed.contains(&call_node) - && all_predecessors_ready( - hugr, - call_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + for &op_node in block_info + .quantum_ops + .iter() + .chain(&block_info.call_nodes) + .chain(&block_info.conditional_nodes) + .chain(&block_info.bool_ops) + .chain(&block_info.tailloop_nodes) + .chain(&extension_ops) { - self.work_queue.push_back(call_node); + act.reset_processed(op_node); } - } - - // Also activate Conditional nodes in this block - // Clear processed state first so they can be re-executed in loops - for &cond_node in &block_info.conditional_nodes { - self.processed.remove(&cond_node); - } - for &cond_node in &block_info.conditional_nodes { - self.nodes_inside_cfg_blocks.remove(&cond_node); - // Skip Conditional nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&cond_node) { - continue; + for child in hugr.children(to_block).chain(dfg_interior.iter().copied()) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) + || self.classical_ops.contains_key(&child) + { + act.reset_processed(child); + } } - if !self.work_queue.contains(&cond_node) && !self.processed.contains(&cond_node) { - self.work_queue.push_back(cond_node); + // Queue order preserves the historical activation order + // (extension/classical before bool ops so their results are + // available; each loop iteration must re-run Calls). Policies: + // Calls and classical ops copy inputs at fire time, so they + // wait for readiness; the rest defer internally or are static. + let submit = |act: &mut ContainerActivation, node: Node, policy: QueuePolicy| { + if self.nodes_inside_tailloops.contains(&node) { + // Skip ops inside TailLoops - they'll be added when the + // loop expands + act.ungate_block_only(node); + } else { + act.queue(node, policy); + } + }; + for &op_node in &block_info.quantum_ops { + submit(&mut act, op_node, QueuePolicy::Always); } - } - - // Also activate other extension ops in this block (like tket.result) - // IMPORTANT: Process extension/classical ops FIRST so their results are available for bool_ops - // Find all extension ops that are children of this block - let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); - for &op_node in &extension_ops { - self.processed.remove(&op_node); - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip extension ops inside TailLoops - if self.nodes_inside_tailloops.contains(&op_node) { - continue; + for &call_node in &block_info.call_nodes { + submit(&mut act, call_node, QueuePolicy::IfReady); } - if !self.work_queue.contains(&op_node) && !self.processed.contains(&op_node) { - self.work_queue.push_back(op_node); + for &cond_node in &block_info.conditional_nodes { + submit(&mut act, cond_node, QueuePolicy::Always); } - } - - // Also activate LoadConstant and classical ops in this block - for child in hugr.children(to_block) { - let op = hugr.get_optype(child); - if matches!(op, OpType::LoadConstant(_)) { - self.processed.remove(&child); - self.nodes_inside_cfg_blocks.remove(&child); - // Skip nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - if !self.work_queue.contains(&child) && !self.processed.contains(&child) { - self.work_queue.push_back(child); - } + for &op_node in &extension_ops { + submit(&mut act, op_node, QueuePolicy::Always); } - // Check for classical ops - if self.classical_ops.contains_key(&child) { - self.processed.remove(&child); - self.nodes_inside_cfg_blocks.remove(&child); - // Skip nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&child) { - continue; + for child in hugr.children(to_block).chain(dfg_interior.iter().copied()) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + submit(&mut act, child, QueuePolicy::Always); } - if !self.work_queue.contains(&child) - && !self.processed.contains(&child) - && all_predecessors_ready( - hugr, - child, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(child); + if self.classical_ops.contains_key(&child) { + submit(&mut act, child, QueuePolicy::IfReady); } } - } - - // Now activate bool ops in this block - // Clear processed state first so they can be re-executed in loops - for &op_node in &block_info.bool_ops { - self.processed.remove(&op_node); - } - for &op_node in &block_info.bool_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip bool ops inside TailLoops - if self.nodes_inside_tailloops.contains(&op_node) { - continue; + for &op_node in &block_info.bool_ops { + submit(&mut act, op_node, QueuePolicy::Always); } - if !self.work_queue.contains(&op_node) && !self.processed.contains(&op_node) { - self.work_queue.push_back(op_node); + // TailLoop nodes queue even when nested inside another loop's + // body tracking: they defer internally until their inputs are + // ready. + for &tl_node in &block_info.tailloop_nodes { + act.queue(tl_node, QueuePolicy::Always); } - } + self.run_activation(hugr, &act); - // Also activate TailLoop nodes in this block - for &tl_node in &block_info.tailloop_nodes { - self.processed.remove(&tl_node); - self.nodes_inside_cfg_blocks.remove(&tl_node); - if !self.work_queue.contains(&tl_node) && !self.processed.contains(&tl_node) { - self.work_queue.push_back(tl_node); - } - } - - let num_ops = block_info.quantum_ops.len(); - let num_calls = block_info.call_nodes.len(); - let num_conditionals = block_info.conditional_nodes.len(); - let num_bool_ops = block_info.bool_ops.len(); - let num_tailloops = block_info.tailloop_nodes.len(); - debug!( - "[TRACE] Activated block {to_block:?} with {num_ops} ops, {num_calls} calls, {num_conditionals} conditionals, {num_bool_ops} bool_ops, {num_tailloops} tailloops" - ); - - // Handle blocks with no operations - immediately complete and transition - // IMPORTANT: Also check for extension_ops and classical_ops, not just quantum/bool/conditional - let has_extension_ops = !extension_ops.is_empty(); - let has_classical_ops = !block_info.classical_ops.is_empty(); - let has_tailloops = !block_info.tailloop_nodes.is_empty(); - - if num_ops == 0 - && num_calls == 0 - && num_conditionals == 0 - && num_bool_ops == 0 - && !has_extension_ops - && !has_classical_ops - && !has_tailloops - { + let num_ops = block_info.quantum_ops.len(); + let num_calls = block_info.call_nodes.len(); + let num_conditionals = block_info.conditional_nodes.len(); + let num_bool_ops = block_info.bool_ops.len(); + let num_tailloops = block_info.tailloop_nodes.len(); debug!( - "[TRACE] Block {to_block:?} has 0 ops and 0 calls, trying to resolve branch" + "[TRACE] Activated block {to_block:?} with {num_ops} ops, {num_calls} calls, {num_conditionals} conditionals, {num_bool_ops} bool_ops, {num_tailloops} tailloops" ); - debug!("[TRACE] Block {to_block:?} has no quantum ops, checking for successors"); - // Mark this block as complete in the active CFG - if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { - active_cfg.completed_blocks.insert(to_block); - } - // Get successors for this block - let successors = block_info.successors.clone(); - if successors.is_empty() { - // No successors - exit block - self.complete_cfg_execution(hugr, cfg_node, to_block); - } else if successors.len() == 1 { - // Single successor - transition immediately - let next_block = successors[0]; - // Check if successor is exit block - if next_block == cfg_info.exit_block { + // Handle blocks with no operations - immediately complete and transition + // IMPORTANT: Also check for extension_ops and classical_ops, not just quantum/bool/conditional + let has_extension_ops = !extension_ops.is_empty(); + let has_classical_ops = !block_info.classical_ops.is_empty(); + let has_tailloops = !block_info.tailloop_nodes.is_empty(); + let has_load_consts = !block_info.load_constants.is_empty(); + + if num_ops == 0 + && num_calls == 0 + && num_conditionals == 0 + && num_bool_ops == 0 + && !has_extension_ops + && !has_classical_ops + && !has_tailloops + && !has_load_consts + { + debug!( + "[TRACE] Block {to_block:?} has 0 ops and 0 calls, trying to resolve branch" + ); + debug!( + "[TRACE] Block {to_block:?} has no quantum ops, checking for successors" + ); + // Mark this block as complete in the active CFG + if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { + active_cfg.completed_blocks.insert(to_block); + } + + // Get successors for this block + let successors = block_info.successors.clone(); + if successors.is_empty() { + // No successors - exit block self.complete_cfg_execution(hugr, cfg_node, to_block); + } else if successors.len() == 1 { + // Single successor - transition immediately. Recurse into + // the canonical transition path (same as the resolved + // multi-successor case below) so the successor gets the + // full two-phase activation: a hand-rolled copy here used + // to skip the stale-value clearing and check Call + // readiness against uncleared producer flags, reviving + // the loop-iteration freeze through empty blocks. + let next_block = successors[0]; + if next_block == cfg_info.exit_block { + self.complete_cfg_execution(hugr, cfg_node, to_block); + } else { + debug!( + "[TRACE] Empty block {to_block:?} transitioning to single successor {next_block:?}" + ); + from_block = to_block; + to_block = next_block; + continue 'transition; + } } else { + // Multiple successors - need to resolve branch debug!( - "[TRACE] Empty block {to_block:?} transitioning to single successor {next_block:?}" + "[TRACE] Block {:?} has {} successors, resolving branch", + to_block, + successors.len() ); - self.propagate_block_outputs_to_successor(hugr, to_block, next_block); - - // Update current block - if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { - active_cfg.current_block = next_block; - } - - // Recursively activate the next block - add all ops to work queue - let next_block_info = cfg_info.blocks.get(&next_block).cloned(); - if let Some(next_info) = next_block_info { - // Quantum ops - for &op_node in &next_info.quantum_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - if !self.work_queue.contains(&op_node) - && !self.processed.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - // Bool ops - for &op_node in &next_info.bool_ops { - self.processed.remove(&op_node); - self.nodes_inside_cfg_blocks.remove(&op_node); - if !self.work_queue.contains(&op_node) - && !self.processed.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - // Conditional nodes - for &cond_node in &next_info.conditional_nodes { - self.processed.remove(&cond_node); - self.nodes_inside_cfg_blocks.remove(&cond_node); - if !self.work_queue.contains(&cond_node) - && !self.processed.contains(&cond_node) - { - self.work_queue.push_back(cond_node); - } - } - // TailLoop nodes - for &tl_node in &next_info.tailloop_nodes { - self.processed.remove(&tl_node); - self.nodes_inside_cfg_blocks.remove(&tl_node); - if !self.work_queue.contains(&tl_node) - && !self.processed.contains(&tl_node) - { - self.work_queue.push_back(tl_node); - } - } - // Call nodes - for &call_node in &next_info.call_nodes { - self.processed.remove(&call_node); - self.nodes_inside_cfg_blocks.remove(&call_node); - if !self.work_queue.contains(&call_node) - && !self.processed.contains(&call_node) - && all_predecessors_ready( - hugr, - call_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(call_node); - } - } - // Also find and add classical ops and extension ops - for child in hugr.children(next_block) { - let op = hugr.get_optype(child); - if matches!(op, OpType::LoadConstant(_)) - || self.classical_ops.contains_key(&child) - || op.as_extension_op().is_some() - { - self.processed.remove(&child); - self.nodes_inside_cfg_blocks.remove(&child); - if !self.work_queue.contains(&child) - && !self.processed.contains(&child) - { - self.work_queue.push_back(child); - } - } - } + if let Some(branch_idx) = self.try_resolve_cfg_block_branch(hugr, to_block) + { debug!( - "[TRACE] Activated next block {:?} with {} quantum ops, {} bool_ops", - next_block, - next_info.quantum_ops.len(), - next_info.bool_ops.len() + "[TRACE] Branch resolved to {branch_idx} for block {to_block:?}" ); - - // Check if the next block is also empty - if so, we need to handle it recursively - // Find extension ops in this block - let next_extension_ops: Vec = - find_extension_ops_in_block(hugr, next_block); - let next_has_extension_ops = !next_extension_ops.is_empty(); - let next_has_classical_ops = !next_info.classical_ops.is_empty(); - - if next_info.quantum_ops.is_empty() - && next_info.call_nodes.is_empty() - && next_info.conditional_nodes.is_empty() - && next_info.bool_ops.is_empty() - && !next_has_extension_ops - && !next_has_classical_ops - && next_info.tailloop_nodes.is_empty() - { - // Next block is also empty - need to continue transitioning - let next_successors = next_info.successors.clone(); - if next_successors.len() == 1 { - let next_next_block = next_successors[0]; - if next_next_block == cfg_info.exit_block { - self.complete_cfg_execution(hugr, cfg_node, next_block); - } else { - // Recursively transition - self.transition_to_cfg_successor( - hugr, - cfg_node, - next_block, - next_next_block, - ); - } - } + if branch_idx < successors.len() { + let next_block = successors[branch_idx]; + debug!( + "[TRACE] Empty block {to_block:?} resolved branch {branch_idx} to {next_block:?}" + ); + from_block = to_block; + to_block = next_block; + continue 'transition; } - } - } - } else { - // Multiple successors - need to resolve branch - debug!( - "[TRACE] Block {:?} has {} successors, resolving branch", - to_block, - successors.len() - ); - if let Some(branch_idx) = self.try_resolve_cfg_block_branch(hugr, to_block) { - debug!("[TRACE] Branch resolved to {branch_idx} for block {to_block:?}"); - if branch_idx < successors.len() { - let next_block = successors[branch_idx]; + // Out-of-range tag = upstream Sum/tag bug. + self.execution_error = Some(format!( + "CFG {cfg_node:?} empty block {to_block:?}: branch tag \ + {branch_idx} out of range ({} successors)", + successors.len() + )); + } else { debug!( - "[TRACE] Empty block {to_block:?} resolved branch {branch_idx} to {next_block:?}" + "[TRACE] Branch NOT resolved for block {to_block:?}, adding to pending" ); - // Recursively transition - self.transition_to_cfg_successor(hugr, cfg_node, to_block, next_block); + // Branch not resolved - add to pending + let block_key = (cfg_node, to_block); + self.pending_cfg_branches.insert(block_key, successors); } - } else { - debug!( - "[TRACE] Branch NOT resolved for block {to_block:?}, adding to pending" - ); - // Branch not resolved - add to pending - let block_key = (cfg_node, to_block); - self.pending_cfg_branches.insert(block_key, successors); } } + return; } } } @@ -864,6 +675,53 @@ impl HugrEngine { // Mark CFG as processed self.processed.insert(cfg_node); self.active_cfgs.remove(&cfg_node); + // Recorded edges must not outlive their CFG walk: a later + // re-invocation (next Call / scan element) starts a fresh chain + // against reset frame state. + self.pending_measurement_propagations + .retain(|(cfg, _, _, _)| *cfg != cfg_node); + + // The ENTRYPOINT's CFG completing means main returned: capture its + // classical return values so pure-classical programs surface them. + // The entrypoint is taken from the HUGR itself when it names a + // FuncDefn; the not-called-not-scanned fallback covers graphs + // whose entrypoint is the module root. + let parent_is_entry_func = hugr.get_parent(cfg_node).is_some_and(|fd| { + matches!(hugr.get_optype(fd), OpType::FuncDefn(_)) + && if matches!(hugr.get_optype(hugr.entrypoint()), OpType::FuncDefn(_)) { + fd == hugr.entrypoint() + } else { + hugr.get_parent(fd) == Some(hugr.module_root()) + && !self.call_targets.values().any(|&target| target == fd) + && !self + .active_scans + .values() + .any(|scan| scan.func_defn_node == fd) + } + }); + if parent_is_entry_func { + // Positional capture: entry i holds port i's value or None. + // Compacting present values would silently relabel every later + // port (and make a 2-port return with port 0 missing surface + // port 1 under "return"). Arity comes from the dataflow + // SIGNATURE -- the portgraph count includes the order port. + use tket::hugr::ops::OpTrait; + let arity = hugr + .get_optype(cfg_node) + .dataflow_signature() + .map_or(0, |sig| sig.output_count()); + let values: Vec> = (0..arity) + .map(|port| { + self.wire_state + .classical_values + .get(&(cfg_node, port)) + .cloned() + }) + .collect(); + if values.iter().any(Option::is_some) { + self.return_values = values; + } + } // Check if this CFG is inside a FuncDefn that's being called self.complete_func_call_if_needed(hugr, cfg_node); @@ -895,25 +753,26 @@ impl HugrEngine { is_relevant, is_extension, self.processed.contains(&succ_node), - self.work_queue.contains(&succ_node) + self.work_queue.contains(succ_node) ); if (is_relevant || is_extension) && !self.processed.contains(&succ_node) - && !self.work_queue.contains(&succ_node) - && all_predecessors_ready( - hugr, - succ_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) + && !self.work_queue.contains(succ_node) + && self.all_predecessors_ready(hugr, succ_node) { debug!("CFG complete: adding successor {succ_node:?} to work queue"); self.work_queue.push_back(succ_node); } } + + // A nested CFG may be the last op of an enclosing case or loop + // body -- run the container completion hooks (mirrors + // complete_tailloop). Block completion needs no hook: blocks do + // not track nested CFG children. + self.check_scan_frame_completion(hugr, cfg_node); + self.check_case_completion(hugr, cfg_node); + self.check_tailloop_body_completion(hugr, cfg_node); } /// Propagate wire mappings from a completed block to a successor block. @@ -923,6 +782,24 @@ impl HugrEngine { hugr: &Hugr, from_block: Node, to_block: Node, + ) { + self.propagate_block_outputs(hugr, from_block, to_block, false); + } + + /// Copy a completed block's outputs onto the successor's Input node. + /// + /// Transition mode (`fill_only == false`) clears every successor Input + /// port first so a port this propagation cannot source starves loudly + /// instead of keeping the previous iteration's value. Measurement + /// replay (`fill_only == true`) runs against LIVE state whose sources + /// a later activation may have legitimately consumed: it never clears + /// and writes only ports that are currently missing. + pub(crate) fn propagate_block_outputs( + &mut self, + hugr: &Hugr, + from_block: Node, + to_block: Node, + fill_only: bool, ) { debug!("[TRACE] propagate_block_outputs_to_successor: from {from_block:?} to {to_block:?}"); let from_output = find_output_node(hugr, from_block); @@ -937,49 +814,105 @@ impl HugrEngine { // Block Output ports: [Sum (port 0), data1, data2, ...] // For CFG blocks with branching, the successor Input ports are: // [payload from Sum (if any), other_outputs...] - // So we need to: - // 1. Check if port 0's source is a Conditional/Tag with payload - // 2. Extract payload values and map them to successor Input ports 0..payload_len-1 - // 3. Map other_outputs (port 1+) to successor Input ports payload_len+ - + // + // The payload ARITY is a TYPE-level fact: the successor's input row + // is fixed, so every branch into it carries exactly + // (successor inputs - other outputs) payload values. Deriving the + // offset from the runtime value's length mis-laid the data ports + // whenever the Sum resolved structurally (bare tag, no payload). let num_output_ports = hugr.num_inputs(from_output); + let target_num_outputs = hugr.num_outputs(to_input); + let num_data_outputs = num_output_ports.saturating_sub(1); + let expected_payload_len = target_num_outputs.saturating_sub(num_data_outputs); + + // Clear EVERY successor Input port before writing: the transition + // batch exempts the Input node from wire clearing (keep_wires) so + // fresh values survive, which means any port this propagation + // cannot source would otherwise keep the PREVIOUS iteration's + // value. Missing sources must starve loudly instead. Replay + // (fill_only) must NOT clear -- it runs against live state. + if !fill_only { + for port_idx in 0..target_num_outputs { + self.wire_state + .classical_values + .remove(&(to_input, port_idx)); + self.wire_state.wire_to_qubit.remove(&(to_input, port_idx)); + } + } // First, check if port 0 (Sum) has payload values from a Conditional let sum_port = IncomingPort::from(0); let mut payload_len = 0; - if let Some((sum_src_node, _)) = hugr.single_linked_output(from_output, sum_port) { + if let Some((sum_src_node, sum_src_port)) = hugr.single_linked_output(from_output, sum_port) + { let sum_src_op = hugr.get_optype(sum_src_node); - - // Check if it's a Conditional - extract payload from virtual output ports - if matches!(sum_src_op, OpType::Conditional(_)) { - // Look for payload values at virtual output ports (1, 2, ...) - let mut idx = 1; - while let Some(value) = self + let sum_wire = (sum_src_node, sum_src_port.index()); + + // If the branch Sum has an executed VALUE (e.g. built by a Tag + // over classical values, like a loop's carried state), its + // payload elements are the successor's first inputs. Traced + // read: the Sum may be produced inside a flattened DFG. + if let Some(ClassicalValue::Sum { values, .. }) = + self.get_input_value(hugr, from_output, 0) + { + payload_len = values.len(); + for (i, value) in values.into_iter().enumerate().take(expected_payload_len) { + if fill_only + && self + .wire_state + .classical_values + .contains_key(&(to_input, i)) + { + continue; + } + debug!( + "[TRACE] Block transition: propagated branch payload {value:?} to {to_input:?}:{i}" + ); + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((to_input, i), *qubit_id); + } + self.wire_state + .classical_values + .insert((to_input, i), value); + } + } + // Check if it's a Conditional -- read the payload recorded for + // the EXACT output port feeding this block's Sum (the payload + // map is keyed by (conditional, output port); the old scheme + // stored payloads at "virtual" classical-value ports, which + // aliased real output ports AND was read from index 1 + // regardless of which output the Tag fed). + else if matches!(sum_src_op, OpType::Conditional(_)) { + let payload = self .wire_state - .classical_values - .get(&(sum_src_node, idx)) + .conditional_payloads + .get(&sum_wire) .cloned() - { - let to_wire = (to_input, idx - 1); - self.wire_state.classical_values.insert(to_wire, value); + .unwrap_or_default(); + for (idx, value) in payload.into_iter().enumerate() { + let to_wire = (to_input, idx); + if !(fill_only && self.wire_state.classical_values.contains_key(&to_wire)) { + self.wire_state.classical_values.insert(to_wire, value); + } payload_len += 1; - idx += 1; } } } - // Now map other_outputs (port 1+) to successor Input ports - // Check if the target Input node has enough outputs to accommodate the payload offset - let target_num_outputs = hugr.num_outputs(to_input); - let num_data_outputs = num_output_ports.saturating_sub(1); - // Only apply payload offset if the target has enough outputs - // This handles exit blocks which don't expect payloads - let effective_payload_len = if payload_len + num_data_outputs <= target_num_outputs { - payload_len - } else { - 0 // Target doesn't have room for payloads, don't offset - }; + // Now map other_outputs (port 1+) to successor Input ports at the + // TYPE-derived offset. A runtime payload arity that disagrees with + // the type row means upstream variant extraction is suspect -- warn, + // but the offset stays type-correct either way. + if payload_len != expected_payload_len { + debug!( + "WARNING: block transition {from_block:?} -> {to_block:?}: runtime payload \ + arity ({payload_len}) != type arity ({expected_payload_len})" + ); + } + let effective_payload_len = expected_payload_len; debug!("[TRACE] num_data_outputs={num_data_outputs}"); debug!( "[TRACE] propagate_block_outputs: from_block={from_block:?}, to_block={to_block:?}, num_data_outputs={num_data_outputs}" @@ -1000,7 +933,13 @@ impl HugrEngine { ); let src_wire = (src_node, src_port.index()); - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { + if let Some(qubit_id) = self.get_input_qubit(hugr, from_output, port_idx + 1) + && !(fill_only + && self + .wire_state + .wire_to_qubit + .contains_key(&(to_input, to_port_idx))) + { self.wire_state .wire_to_qubit .insert((to_input, to_port_idx), qubit_id); @@ -1014,8 +953,16 @@ impl HugrEngine { ); } + let target_filled = fill_only + && self + .wire_state + .classical_values + .contains_key(&(to_input, to_port_idx)); + // Also propagate classical values - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { + if target_filled { + // Fill-only replay never overwrites a live value. + } else if let Some(value) = self.get_input_value(hugr, from_output, port_idx + 1) { let to_wire = (to_input, to_port_idx); debug!( "[TRACE] Block transition: propagated classical value {value:?} from {src_wire:?} to {to_wire:?}" @@ -1056,10 +1003,12 @@ impl HugrEngine { if let Some(value) = self.wire_state.classical_values.get(&input_wire).cloned() { let to_wire = (to_input, to_port_idx); - debug!( - "[TRACE] Fallback: propagating {value:?} from input {input_wire:?} to {to_wire:?}" - ); - self.wire_state.classical_values.insert(to_wire, value); + if !(fill_only && self.wire_state.classical_values.contains_key(&to_wire)) { + debug!( + "[TRACE] Fallback: propagating {value:?} from input {input_wire:?} to {to_wire:?}" + ); + self.wire_state.classical_values.insert(to_wire, value); + } } } } @@ -1072,36 +1021,128 @@ impl HugrEngine { /// happens before measurement results are available. This function re-propagates /// values after measurement results are stored. pub(crate) fn repropagate_measurement_values(&mut self, hugr: &Hugr) { - // Take ownership of the pending list to avoid borrow issues - let pending: Vec<_> = std::mem::take(&mut self.pending_measurement_propagations); - - for (_cfg_node, from_block, to_block) in pending { - self.propagate_block_outputs_to_successor(hugr, from_block, to_block); + // Replay every recorded edge IN ORDER, FILL-ONLY: writes land only + // on ports that are currently missing, and nothing is cleared. + // Fill-only is what makes replay safe against live state -- a full + // re-propagation cleared the successor's inputs and re-read source + // wires that a later activation may have legitimately destroyed + // (self-loop blocks corrupted an in-flight iteration this way). + // Ordered chain replay is what carries a late measurement value + // across empty-block hops. Edges stay recorded (fill-only is + // idempotent); they are purged when their target re-activates or + // their CFG completes. + // Only each CFG's LATEST cascade replays: older cascades' edges + // point at sources a later iteration may have rewritten, and + // fill-only stops overwrites but not wrong FILLS of legitimately + // missing ports. + let mut latest: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for (cfg_node, _, _, cascade) in &self.pending_measurement_propagations { + let entry = latest.entry(*cfg_node).or_insert(*cascade); + *entry = (*entry).max(*cascade); + } + let pending = self.pending_measurement_propagations.clone(); + for (cfg_node, from_block, to_block, cascade) in pending { + if self.active_cfgs.contains_key(&cfg_node) && latest.get(&cfg_node) == Some(&cascade) { + self.propagate_block_outputs(hugr, from_block, to_block, true); + } } } /// Propagate wire mappings from final block to CFG outputs. pub(crate) fn propagate_cfg_outputs(&mut self, hugr: &Hugr, cfg_node: Node, final_block: Node) { + use tket::hugr::ops::OpTrait; + let Some(output_node) = find_output_node(hugr, final_block) else { debug!("No Output node found in final block {final_block:?}"); return; }; - // Block Output: port 0 = Sum (control), ports 1+ = data - // CFG outputs correspond to data ports (skip the Sum) + // Block Output: port 0 = Sum (control), ports 1+ = data. + // When the Sum is built by a Tag, its inputs are the PAYLOAD carried + // into the exit variant -- these become the first CFG outputs (e.g. a + // called function's return value rides in the Tag payload, not on the + // data ports). The payload ARITY is type-level: CFG outputs minus + // the final block's data ports (a bare-tag structural resolution + // must not collapse the offset to zero). let num_data_outputs = hugr.num_inputs(output_node).saturating_sub(1); + let expected_payload_len = hugr + .get_optype(cfg_node) + .dataflow_signature() + .map_or(0, |sig| sig.output_count()) + .saturating_sub(num_data_outputs); + let mut payload_len = 0; + if let Some((sum_src, sum_src_port)) = + hugr.single_linked_output(output_node, IncomingPort::from(0)) + { + // Prefer the executed Sum VALUE regardless of what built it (a + // direct Tag, or a Sum routed through a Conditional's output): + // its payload elements are the CFG outputs (a called function's + // return value rides here). + let _sum_wire = (sum_src, sum_src_port.index()); + if let Some(ClassicalValue::Sum { values, .. }) = + self.get_input_value(hugr, output_node, 0) + { + payload_len = values.len(); + for (i, value) in values.into_iter().enumerate().take(expected_payload_len) { + debug!("CFG {cfg_node:?} output {i}: mapped payload value {value:?}"); + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((cfg_node, i), *qubit_id); + } + self.wire_state + .classical_values + .insert((cfg_node, i), value); + } + } else if matches!(hugr.get_optype(sum_src), OpType::Tag(_)) { + // Structural fallback (e.g. linear payloads: Tags over qubits + // do not execute as classical ops). + let num_payload = hugr + .get_optype(sum_src) + .dataflow_signature() + .map_or(0, |sig| sig.input_count()); + for i in 0..num_payload { + if let Some(qubit_id) = self.get_input_qubit(hugr, sum_src, i) { + self.wire_state + .wire_to_qubit + .insert((cfg_node, i), qubit_id); + debug!("CFG {cfg_node:?} output {i}: mapped payload qubit {qubit_id:?}"); + } + if let Some(value) = self.get_input_value(hugr, sum_src, i) { + debug!("CFG {cfg_node:?} output {i}: mapped payload value {value:?}"); + self.wire_state + .classical_values + .insert((cfg_node, i), value); + } + } + payload_len = num_payload; + } + } + // CFG outputs after the payload correspond to the data ports, at + // the TYPE-derived offset. + if payload_len != expected_payload_len { + debug!( + "WARNING: CFG {cfg_node:?} exit: runtime payload arity ({payload_len}) != \ + type arity ({expected_payload_len})" + ); + } for port_idx in 0..num_data_outputs { let block_port = IncomingPort::from(port_idx + 1); // Skip Sum port + let cfg_out_idx = expected_payload_len + port_idx; - if let Some((src_node, src_port)) = hugr.single_linked_output(output_node, block_port) { - let src_wire = (src_node, src_port.index()); - - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { + if hugr.single_linked_output(output_node, block_port).is_some() { + if let Some(qubit_id) = self.get_input_qubit(hugr, output_node, port_idx + 1) { self.wire_state .wire_to_qubit - .insert((cfg_node, port_idx), qubit_id); - debug!("CFG {cfg_node:?} output {port_idx}: mapped qubit {qubit_id:?}"); + .insert((cfg_node, cfg_out_idx), qubit_id); + debug!("CFG {cfg_node:?} output {cfg_out_idx}: mapped qubit {qubit_id:?}"); + } + if let Some(value) = self.get_input_value(hugr, output_node, port_idx + 1) { + debug!("CFG {cfg_node:?} output {cfg_out_idx}: mapped value {value:?}"); + self.wire_state + .classical_values + .insert((cfg_node, cfg_out_idx), value); } } } diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index 9847b3b6f..e53269de1 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -44,8 +44,9 @@ use tket::hugr::ops::OpType; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; +use crate::engine::activation::{ContainerActivation, QueuePolicy}; use crate::engine::analysis::find_input_node; -use crate::engine::types::{ActiveCaseInfo, QuantumOp}; +use crate::engine::types::{ActiveCaseInfo, ClassicalValue, QuantumOp}; impl HugrEngine { /// Try to resolve the control value for a Conditional node. @@ -106,18 +107,31 @@ impl HugrEngine { /// Try to resolve any pending conditionals that were waiting for measurement results. pub(crate) fn try_resolve_pending_conditionals(&mut self) { + if self.pending_conditionals.is_empty() { + return; + } let hugr = match &self.hugr { Some(h) => h.clone(), None => return, }; - // Collect conditionals that can now be resolved + // Collect conditionals that can now be resolved. A pending entry + // whose node is already processed was expanded through the queue + // path in the meantime -- expanding it again would re-run its case. let mut to_resolve = Vec::new(); - for &cond_node in self.pending_conditionals.keys() { - if let Some(branch_index) = self.try_resolve_conditional_control(&hugr, cond_node) { + let mut already_expanded = Vec::new(); + for &cond_node in &self.pending_conditionals { + if self.processed.contains(&cond_node) { + already_expanded.push(cond_node); + } else if let Some(branch_index) = + self.try_resolve_conditional_control(&hugr, cond_node) + { to_resolve.push((cond_node, branch_index)); } } + for cond_node in already_expanded { + self.pending_conditionals.remove(&cond_node); + } // Resolve them for (cond_node, branch_index) in to_resolve { @@ -126,11 +140,14 @@ impl HugrEngine { let entry_nodes = self.expand_conditional(&hugr, cond_node, branch_index); let num_entry_nodes = entry_nodes.len(); for entry_node in entry_nodes { - if !self.work_queue.contains(&entry_node) && !self.processed.contains(&entry_node) { + if !self.work_queue.contains(entry_node) && !self.processed.contains(&entry_node) { self.work_queue.push_back(entry_node); } } + // expand_conditional runs the zero-op-case completion hooks + // itself, so nothing more is needed here. + debug!( "Resolved pending Conditional {cond_node:?}, branch {branch_index} selected, added {num_entry_nodes} entry nodes" ); @@ -145,11 +162,15 @@ impl HugrEngine { for (case_node, case_info) in &self.active_cases { if case_info.ops_in_case.contains(&processed_node) { - // Check if all ops in this Case are now processed + // A case is complete only when every tracked op is processed + // AND no tracked nested container is still mid-flight: a + // nested Conditional is marked processed at EXPANSION (its + // outputs exist only once its own case completes), and + // active TailLoops/Calls are still producing values. let all_done = case_info .ops_in_case .iter() - .all(|op| self.processed.contains(op)); + .all(|op| self.node_settled(*op)); if all_done { completed_cases.push((*case_node, case_info.conditional_node)); @@ -157,11 +178,34 @@ impl HugrEngine { } } - // Propagate outputs for completed cases + // Propagate outputs for completed cases. Removing the case UP FRONT + // doubles as a re-entrancy guard: the check_case_completion recursion + // below can complete a case this loop also collected. for (case_node, cond_node) in completed_cases { + if self.active_cases.remove(&case_node).is_none() { + continue; + } debug!("Case {case_node:?} complete, propagating outputs to Conditional {cond_node:?}"); self.propagate_conditional_outputs(hugr, cond_node, case_node); - self.active_cases.remove(&case_node); + + // The Conditional's outputs exist only now: re-run the checks + // that treat the Conditional as complete (block completion gates + // on the case being done) and wake its consumers. The Conditional + // may itself be the last op of an ENCLOSING case, so check case + // completion too (recursing one nesting level per call). + self.check_scan_frame_completion(hugr, cond_node); + self.check_case_completion(hugr, cond_node); + self.check_cfg_block_completion(hugr, cond_node); + self.check_tailloop_body_completion(hugr, cond_node); + // A TailLoop whose control Sum rides through this Conditional's + // output may have parked in pending_tailloop_control before the + // value existed -- re-attempt it here (measurement rounds are + // the only other retry point, and a purely classical case + // completion may never be followed by one). + self.try_resolve_pending_tailloops(); + self.try_resolve_pending_cfg_branches(); + self.queue_ready_successors(hugr, cond_node); + self.retry_deferred_nodes(); } } @@ -193,75 +237,194 @@ impl HugrEngine { let out_in_port = IncomingPort::from(port_idx); // Find what's connected to this Output node input - if let Some((src_node, src_port)) = hugr.single_linked_output(output_node, out_in_port) + if let Some((src_node, _src_port)) = hugr.single_linked_output(output_node, out_in_port) { - let src_wire = (src_node, src_port.index()); - - // Check if we have a qubit mapping for this wire - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { + // Reads go through the tracing layer so a case output + // produced inside a flattened DFG resolves. + if let Some(qubit_id) = self.get_input_qubit(hugr, output_node, port_idx) { // Map to the Conditional's output port self.wire_state .wire_to_qubit .insert((cond_node, port_idx), qubit_id); debug!( - "Mapped Conditional {cond_node:?} output {port_idx} to qubit {qubit_id:?} (from {src_wire:?})" + "Mapped Conditional {cond_node:?} output {port_idx} to qubit {qubit_id:?}" ); } - // Check if we have a classical value for this wire - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { - self.wire_state - .classical_values - .insert((cond_node, port_idx), value.clone()); + // Check if we have a classical value for this wire. When the + // source Tag executed as a classical op this is the real Sum + // value -- the structural fallback below must NOT overwrite + // it with just the tag number. + let has_value = if let Some(value) = + self.get_input_value(hugr, output_node, port_idx) + { debug!( - "Mapped Conditional {cond_node:?} output {port_idx} to classical value {value:?} (from {src_wire:?})" + "Mapped Conditional {cond_node:?} output {port_idx} to classical value {value:?}" ); - } + self.wire_state + .classical_values + .insert((cond_node, port_idx), value); + true + } else { + false + }; - // Check if the source is a Tag node - store its tag value and payload + // Structural fallback: if the source is an unexecuted Tag + // node, store its tag value and payload let src_op = hugr.get_optype(src_node); - if let OpType::Tag(tag_op) = src_op { + if !has_value && let OpType::Tag(tag_op) = src_op { let tag_value = tag_op.tag; #[allow(clippy::cast_possible_wrap)] // Tag indices are small self.wire_state .classical_values .insert((cond_node, port_idx), ClassicalValue::Int(tag_value as i64)); - // Also extract and store the Tag's inputs (Sum payload values) - // Store them at "virtual" output ports (1, 2, ...) on the Conditional - // These will be used during CFG block transitions + // Also extract and store the Tag's inputs (Sum payload + // values) in the DEDICATED payload map, keyed by this + // output port, for CFG block transitions to consume. + // Whole-vector replace doubles as the stale-run clear + // (a previous, longer payload cannot survive), and a + // not-yet-ready element truncates the vector -- the + // consumer sees exactly the ready prefix, never a stale + // value. Elements read through the tracing layer so a + // payload produced inside a flattened DFG resolves. let num_tag_inputs = hugr.num_inputs(src_node); + let mut payload = Vec::with_capacity(num_tag_inputs); for payload_idx in 0..num_tag_inputs { - let tag_in_port = IncomingPort::from(payload_idx); - if let Some((payload_src_node, payload_src_port)) = - hugr.single_linked_output(src_node, tag_in_port) + if let Some(payload_value) = + self.get_input_value(hugr, src_node, payload_idx) { - let payload_src_wire = (payload_src_node, payload_src_port.index()); - if let Some(payload_value) = self - .wire_state - .classical_values - .get(&payload_src_wire) - .cloned() - { - // Store at virtual output port (port_idx + 1 + payload_idx) - // This allows CFG block transitions to find the payload values - let virtual_port = port_idx + 1 + payload_idx; - debug!( - "Conditional {cond_node:?} Tag payload {payload_idx}: {payload_value:?} at virtual port {virtual_port}" - ); - self.wire_state - .classical_values - .insert((cond_node, virtual_port), payload_value); - } else { - debug!("No payload value at {payload_src_wire:?}"); - } + debug!( + "Conditional {cond_node:?} output {port_idx} Tag payload {payload_idx}: {payload_value:?}" + ); + payload.push(payload_value); + } else { + debug!( + "Conditional {cond_node:?} Tag payload {payload_idx} not ready; truncating" + ); + break; } } + self.wire_state + .conditional_payloads + .insert((cond_node, port_idx), payload); } } } } + /// Copy the Conditional's control payload and data inputs into the + /// selected Case's Input node ports. + /// + /// Called at expansion and again when measurement results arrive: a case + /// can expand as soon as its own control resolves while other data + /// inputs (e.g. a later measurement) do not exist yet, and the one-shot + /// copy would leave those Case Input ports empty forever. + pub(crate) fn propagate_case_inputs(&mut self, hugr: &Hugr, cond_node: Node, input_node: Node) { + // The Case's Input row is [selected variant's PAYLOAD] ++ + // [other inputs]. The payload ARITY comes from the Conditional's + // TYPE (sum_rows of the selected variant), never from the control + // value: when the branch resolves through the structural Tag + // fallback the value is a bare tag with no payload, and deriving + // the offset from it would land the data inputs on the payload's + // ports. + let payload_len = hugr + .get_parent(input_node) + .and_then(|case_node| { + let branch = hugr.children(cond_node).position(|c| c == case_node)?; + if let OpType::Conditional(cond_op) = hugr.get_optype(cond_node) { + cond_op + .sum_rows + .get(branch) + .map(tket::hugr::types::TypeRow::len) + } else { + None + } + }) + .unwrap_or(0); + if let Some((ctrl_src, ctrl_port)) = + hugr.single_linked_output(cond_node, IncomingPort::from(0)) + && let Some(ClassicalValue::Sum { values, .. }) = self + .wire_state + .classical_values + .get(&(ctrl_src, ctrl_port.index())) + .cloned() + { + if values.len() != payload_len { + debug!( + "Conditional {cond_node:?}: control payload arity {} != type arity \ + {payload_len}; propagating what exists at type offsets", + values.len() + ); + } + for (i, value) in values.into_iter().enumerate().take(payload_len) { + debug!("Propagated control payload {value:?} to Case Input ({input_node:?}, {i})"); + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((input_node, i), *qubit_id); + } + self.wire_state + .classical_values + .insert((input_node, i), value); + } + } + + // Propagate ALL wires (qubit and classical) from Conditional inputs to the Case's Input node + // Port 0 is the control (Sum type), ports 1+ are data inputs + // following the payload values unpacked above. + let num_cond_inputs = hugr.num_inputs(cond_node); + + // Start from port 1 (skip control), propagate all inputs. Reads go + // through the tracing layer so a data input produced inside a + // flattened DFG resolves. + for port_idx in 1..num_cond_inputs { + let input_output_idx = payload_len + port_idx - 1; + + // Propagate qubit mappings + if let Some(qubit_id) = self.get_input_qubit(hugr, cond_node, port_idx) { + self.wire_state + .wire_to_qubit + .insert((input_node, input_output_idx), qubit_id); + debug!( + "Propagated qubit {qubit_id:?} to Input node {input_node:?} port {input_output_idx}" + ); + } + + // Also propagate classical values (integers, bools, etc.) + if let Some(value) = self.get_input_value(hugr, cond_node, port_idx) { + debug!( + "Propagated classical value {value:?} to Case Input ({input_node:?}, {input_output_idx})" + ); + self.wire_state + .classical_values + .insert((input_node, input_output_idx), value); + } + } + } + + /// Re-run case-input propagation for every active case (measurement + /// results may have landed after the case expanded). + pub(crate) fn repropagate_active_case_inputs(&mut self, hugr: &Hugr) { + // Unlike the CFG replay path, this writes WITHOUT clearing first: + // propagate_case_inputs copies whatever sources exist right now + // onto the Case's Input ports, overwriting like-for-like. That is + // safe here because a case expands exactly once per conditional + // resolution (its Input ports have a single producer -- this copy), + // whereas CFG block Inputs are rewritten every iteration and so + // need fill-only replay to avoid clobbering live state. + let targets: Vec<(Node, Node)> = self + .active_cases + .iter() + .filter_map(|(&case_node, info)| { + find_input_node(hugr, case_node).map(|input| (info.conditional_node, input)) + }) + .collect(); + for (cond_node, input_node) in targets { + self.propagate_case_inputs(hugr, cond_node, input_node); + } + } + /// Expand a Conditional by selecting the appropriate Case branch. /// Returns the entry nodes of the selected Case that should be added to the work queue. pub(crate) fn expand_conditional( @@ -276,12 +439,15 @@ impl HugrEngine { }; if branch_index >= cond_info.cases.len() { - debug!( - "Branch index {} out of range for Conditional {:?} with {} cases", - branch_index, - cond_node, + // An out-of-range tag means an upstream Sum-propagation bug; + // swallowing it here left the Conditional neither processed nor + // pending -- an eventual stall naming innocent starved + // consumers. Poison instead, like the CFG branch sites. + self.execution_error = Some(format!( + "Conditional {cond_node:?}: branch tag {branch_index} out of range \ + ({} cases)", cond_info.cases.len() - ); + )); return Vec::new(); } @@ -290,48 +456,36 @@ impl HugrEngine { "Expanding Conditional {cond_node:?} branch {branch_index} -> Case {selected_case:?}" ); + // This conditional may ALSO be parked in pending_conditionals (it + // deferred once, then a loop iteration re-queued it and the queue + // path expanded it). Drop the pending entry, or a later retry wave + // expands it a second time -- with case quantum ops reset per + // expansion that re-allocates and re-measures qubits (observed as + // gates and measurements landing on disjoint qubit generations). + self.pending_conditionals.remove(&cond_node); + // Find the Input node inside the selected Case // Operations inside the Case connect to this Input node, not to the Case node itself let input_node = find_input_node(hugr, selected_case); if let Some(input_node) = input_node { debug!("Case {selected_case:?} has Input node {input_node:?}"); - - // Propagate ALL wires (qubit and classical) from Conditional inputs to the Case's Input node - // Port 0 is the control (Sum type), ports 1+ are data inputs - // The Case's Input node outputs correspond to the Conditional's non-control inputs - let num_cond_inputs = hugr.num_inputs(cond_node); - - // Start from port 1 (skip control), propagate all inputs - for port_idx in 1..num_cond_inputs { - let cond_in_port = IncomingPort::from(port_idx); - if let Some((src_node, src_port)) = - hugr.single_linked_output(cond_node, cond_in_port) - { - let src_wire = (src_node, src_port.index()); - let input_output_idx = port_idx - 1; - - // Propagate qubit mappings - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - self.wire_state - .wire_to_qubit - .insert((input_node, input_output_idx), qubit_id); - debug!( - "Propagated qubit {qubit_id:?} to Input node {input_node:?} port {input_output_idx}" - ); - } - - // Also propagate classical values (integers, bools, etc.) - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { - debug!( - "Propagated classical value {value:?} from {src_wire:?} to Case Input ({input_node:?}, {input_output_idx})" - ); - self.wire_state - .classical_values - .insert((input_node, input_output_idx), value); - } - } + // Clear the Case Input's previous values first: + // propagate_case_inputs only overwrites ports whose source value + // currently exists, so a data input that is missing at expansion + // time (e.g. a measurement landing later) would otherwise leave + // the PREVIOUS iteration's value for consumers to read before + // the re-propagation repairs it. + let num_outputs = hugr.num_outputs(input_node); + for port_idx in 0..num_outputs { + self.wire_state + .classical_values + .remove(&(input_node, port_idx)); + self.wire_state + .wire_to_qubit + .remove(&(input_node, port_idx)); } + self.propagate_case_inputs(hugr, cond_node, input_node); } else { debug!("No Input node found in Case {selected_case:?}"); } @@ -350,11 +504,87 @@ impl HugrEngine { ops_in_case.insert(child); } } + self.executed_containers.insert(selected_case, "Case"); + // Build the case activation batch through the shared two-phase + // mechanism: every processed flag and stale output wire in the case + // clears BEFORE any readiness check, so consumers cannot pass + // readiness against the previous execution's flags of a + // not-yet-cleared producer (same discipline as CFG/TailLoop + // re-activation). Quantum ops are queued separately as entry nodes + // by the caller, so they only reset and ungate here; a case + // re-expanded on a later loop iteration must re-emit its gates. + let mut act = ContainerActivation::new(); + for &node in &ops_in_case { + act.reset(node); + act.ungate(node); + } + + // The Case's classical, bool, and extension ops must also execute + // before its outputs propagate (propagating early copies missing + // values downstream). Unblock them from the inside-cases guard, + // queue the ready ones, and track them all for completion. + let case_classical = + crate::engine::analysis::find_classical_ops_in_block(hugr, selected_case); + let case_bools = crate::engine::analysis::find_bool_ops_in_block(hugr, selected_case); + let case_extension: Vec = + crate::engine::analysis::find_extension_ops_in_block(hugr, selected_case) + .into_iter() + .filter(|n| { + !self.quantum_ops.contains_key(n) + && !case_classical.contains(n) + && !case_bools.contains(n) + }) + .collect(); + // LoadConstants feeding the Case's Output (e.g. a loop's + // continue-flag bool) also count: queue and track them like blocks + // do at activation. + let case_load_consts: Vec = hugr + .children(selected_case) + .filter(|&child| matches!(hugr.get_optype(child), OpType::LoadConstant(_))) + .collect(); + // Nested control-flow containers (Call/TailLoop/Conditional children + // of the case) must be tracked too: a case whose only content is a + // Call or loop would otherwise register with an empty/completed op + // set and propagate outputs before the nested work even starts. + let mut case_calls: Vec = Vec::new(); + let mut case_containers: Vec = Vec::new(); + let mut case_cfgs: Vec = Vec::new(); + for child in hugr.children(selected_case) { + match hugr.get_optype(child) { + OpType::Call(_) => case_calls.push(child), + OpType::TailLoop(_) | OpType::Conditional(_) => case_containers.push(child), + // CFGs copy their inputs one-shot at activation, so they + // queue like Calls: only once their producers ran. + OpType::CFG(_) => case_cfgs.push(child), + _ => {} + } + } + for &op_node in case_classical + .iter() + .chain(case_bools.iter()) + .chain(case_extension.iter()) + .chain(case_load_consts.iter()) + .chain(case_calls.iter()) + .chain(case_cfgs.iter()) + { + act.reset(op_node); + act.queue(op_node, QueuePolicy::IfReady); + ops_in_case.insert(op_node); + } + // TailLoops and nested Conditionals defer internally until their + // control resolves, so they queue without a readiness check. + for &op_node in &case_containers { + act.reset(op_node); + act.queue(op_node, QueuePolicy::Always); + ops_in_case.insert(op_node); + } + self.run_activation(hugr, &act); // Register this Case as active so we can propagate outputs when complete - if ops_in_case.is_empty() { + let case_completed_inline = ops_in_case.is_empty(); + if case_completed_inline { // No ops in this Case - propagate outputs immediately - debug!("Case {selected_case:?} has no quantum ops, propagating outputs immediately"); + debug!("Case {selected_case:?} has no ops, propagating outputs immediately"); self.propagate_conditional_outputs(hugr, cond_node, selected_case); } else { self.active_cases.insert( @@ -376,6 +606,23 @@ impl HugrEngine { // Mark the Conditional as processed self.processed.insert(cond_node); + // A zero-op case completed inline above with no later completion + // event to observe it: check_case_completion never fires for it, so + // run the same wake-up sequence here (after the processed mark, or + // the readiness checks still see the Conditional as pending). A + // consumer gated solely on this Conditional -- e.g. a zero-argument + // Call sequenced behind it by an order edge -- otherwise starves. + if case_completed_inline { + self.check_scan_frame_completion(hugr, cond_node); + self.check_case_completion(hugr, cond_node); + self.check_cfg_block_completion(hugr, cond_node); + self.check_tailloop_body_completion(hugr, cond_node); + self.try_resolve_pending_tailloops(); + self.try_resolve_pending_cfg_branches(); + self.queue_ready_successors(hugr, cond_node); + self.retry_deferred_nodes(); + } + entry_nodes } diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs new file mode 100644 index 000000000..f28da92f5 --- /dev/null +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -0,0 +1,389 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Higher-order array `scan` execution. +//! +//! `collections.array.scan` / `collections.borrow_arr.scan` fold a function +//! value over an array: `array, (T1, *A -> T2, *A), *A -> +//! array, *A`. The engine runs the scanned function once per element +//! through the same frame machinery as Calls, so quantum ops inside the +//! function (e.g. `measure_array`'s per-qubit measure) go through real +//! measurement rounds. Elements fold left to right, matching the order the +//! measurements were declared in. + +use log::debug; +use tket::hugr::ops::OpTrait; +use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; + +use crate::engine::HugrEngine; +use crate::engine::activation::{ContainerActivation, QueuePolicy}; +use crate::engine::analysis::collect_descendants; +use crate::engine::handlers::HandlerOutcome; +use crate::engine::types::{ActiveScanInfo, ClassicalValue}; + +impl HugrEngine { + /// Handle a `scan` op: start folding, or defer until every input + /// (array, function value, accumulators) resolves and the scanned + /// function's frame is free. + pub(crate) fn handle_scan_op(&mut self, hugr: &Hugr, node: Node) -> HandlerOutcome { + if self.active_scans.contains_key(&node) { + // Already folding: frame completions drive progress; retries of + // the scan node itself have nothing to do. + return HandlerOutcome::Defer; + } + + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) else { + debug!("scan at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(ClassicalValue::FuncRef(func_defn_node, func_type_args)) = + self.get_input_value(hugr, node, 1) + else { + debug!("scan at {node:?}: function value not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(sig) = hugr.get_optype(node).dataflow_signature() else { + return HandlerOutcome::Fault(format!("scan at {node:?} has no dataflow signature")); + }; + let mut accs = Vec::new(); + for port in 2..sig.input_count() { + let Some(value) = self.get_input_value(hugr, node, port) else { + debug!("scan at {node:?}: accumulator {port} not ready, deferring"); + return HandlerOutcome::Defer; + }; + accs.push(value); + } + + if !self.func_defns.contains_key(&func_defn_node) { + return HandlerOutcome::Fault(format!( + "scan at {node:?} references unknown FuncDefn {func_defn_node:?}" + )); + } + // The scanned function's single execution frame must be free. + let frame_in_use = self + .active_calls + .values() + .any(|info| info.func_defn_node == func_defn_node) + || self + .active_scans + .values() + .any(|scan| scan.func_defn_node == func_defn_node); + if frame_in_use { + debug!("scan at {node:?}: FuncDefn {func_defn_node:?} frame in use, deferring"); + return HandlerOutcome::Defer; + } + + let mut state = ActiveScanInfo { + scan_node: node, + func_defn_node, + remaining: elements.into(), + results: Vec::new(), + accs, + type_args: func_type_args, + frame_ops: std::collections::BTreeSet::new(), + }; + if state.remaining.is_empty() { + // Zero-length array: complete immediately. + self.store_scan_outputs(&state); + debug!("scan at {node:?}: empty array, completed immediately"); + return HandlerOutcome::Processed; + } + debug!( + "scan at {node:?}: folding {} elements through FuncDefn {func_defn_node:?}", + state.remaining.len() + ); + let element = state + .remaining + .pop_front() + .expect("non-empty checked above"); + self.active_scans.insert(node, state); + if self.launch_scan_iteration(hugr, node, element) { + // Pure-passthrough frame: fold the remaining elements now. + self.advance_scan(hugr, node); + } + // Like a Call, the scan node stays unprocessed while its frame + // runs; completion marks it. When advance_scan already completed + // the whole fold synchronously, the dispatcher's processed guard + // prevents this Defer from re-parking a finished node. + HandlerOutcome::Defer + } + + /// Run one element through the scanned function's frame. Returns true + /// when the frame completed INSTANTLY (an empty dataflow passthrough + /// body): the caller must then advance in ITS loop -- calling back into + /// completion here would recurse once per element. + fn launch_scan_iteration( + &mut self, + hugr: &Hugr, + scan_node: Node, + element: ClassicalValue, + ) -> bool { + let Some(state) = self.active_scans.get(&scan_node) else { + // Unreachable from current call sites; if ever reached, a + // silent false would impersonate a launched frame whose + // completion never comes. + self.execution_error = Some(format!( + "scan {scan_node:?}: launch without an active scan state" + )); + return false; + }; + let func_defn_node = state.func_defn_node; + let accs = state.accs.clone(); + let Some(func_info) = self.func_defns.get(&func_defn_node).cloned() else { + self.execution_error = Some(format!( + "scan {scan_node:?}: FuncDefn {func_defn_node:?} vanished mid-fold" + )); + return false; + }; + + // Reset the frame exactly like a Call re-activation: descendants' + // processed flags AND stale wires clear (previous element's values + // must not leak into this one), and the previous iteration's + // executed-container records are invalidated. + let mut descendants = std::collections::BTreeSet::new(); + collect_descendants(hugr, func_defn_node, &mut descendants); + let mut act = ContainerActivation::new(); + for node in &descendants { + self.nodes_inside_func_defns.remove(node); + act.reset(*node); + self.executed_containers.remove(node); + } + act.keep_wires(func_info.input_node); + if let Some(cfg_node) = func_info.cfg_node { + act.reset_processed(cfg_node); + } + // A plain dataflow body (no CFG) executes as a tracked op set: queue + // every non-boundary child; their collective completion finishes + // the element (check_scan_frame_completion). + let mut frame_ops = std::collections::BTreeSet::new(); + if func_info.cfg_node.is_none() { + for child in hugr.children(func_defn_node) { + let op = hugr.get_optype(child); + if matches!( + op, + tket::hugr::ops::OpType::Input(_) + | tket::hugr::ops::OpType::Output(_) + | tket::hugr::ops::OpType::Const(_) + ) { + continue; + } + let policy = match op { + tket::hugr::ops::OpType::Conditional(_) + | tket::hugr::ops::OpType::TailLoop(_) + | tket::hugr::ops::OpType::LoadConstant(_) => QueuePolicy::Always, + _ => QueuePolicy::IfReady, + }; + act.queue(child, policy); + frame_ops.insert(child); + } + } + if let Some(state) = self.active_scans.get_mut(&scan_node) { + state.frame_ops = frame_ops; + } + self.run_activation(hugr, &act); + + // Element -> Input port 0; accumulators -> Input ports 1.. + let input_wire = (func_info.input_node, 0); + if let ClassicalValue::QubitRef(qubit_id) = &element { + self.wire_state.wire_to_qubit.insert(input_wire, *qubit_id); + } else { + self.wire_state.wire_to_qubit.remove(&input_wire); + } + self.wire_state.classical_values.insert(input_wire, element); + for (i, acc) in accs.into_iter().enumerate() { + self.wire_state + .classical_values + .insert((func_info.input_node, 1 + i), acc); + } + + if let Some(cfg_node) = func_info.cfg_node { + debug!("scan {scan_node:?}: launching frame CFG {cfg_node:?}"); + if !self.work_queue.contains(cfg_node) { + self.work_queue.push_front(cfg_node); + } + false + } else { + debug!("scan {scan_node:?}: launched dataflow frame"); + // An EMPTY dataflow body (pure passthrough) completes at once; + // the caller advances iteratively. + self.active_scans + .get(&scan_node) + .is_some_and(|state| state.frame_ops.is_empty()) + } + } + + /// Check whether an active scan's DATAFLOW frame finished: every + /// tracked body op processed and no nested container still active. + /// Advances the scan when it did. `processed_node` scopes the check to + /// scans whose frame contains it (or the scan node itself for the + /// empty-frame case). + pub(crate) fn check_scan_frame_completion(&mut self, hugr: &Hugr, processed_node: Node) { + let candidates: Vec = self + .active_scans + .iter() + .filter(|(scan_node, state)| { + **scan_node == processed_node || state.frame_ops.contains(&processed_node) + }) + .filter(|(_, state)| { + // CFG-bodied frames complete via complete_func_call_if_needed. + self.func_defns + .get(&state.func_defn_node) + .is_some_and(|info| info.cfg_node.is_none()) + }) + .map(|(&scan_node, _)| scan_node) + .collect(); + for scan_node in candidates { + let Some(state) = self.active_scans.get(&scan_node) else { + continue; + }; + let all_done = state.frame_ops.iter().all(|op| self.node_settled(*op)); + if all_done { + debug!("scan {scan_node:?}: dataflow frame complete"); + self.advance_scan(hugr, scan_node); + } + } + } + + /// Route a completed `FuncDefn` CFG to its scan, if one is folding + /// through it. Returns true when the completion belonged to a scan. + pub(crate) fn continue_scan_after_frame(&mut self, hugr: &Hugr, cfg_node: Node) -> bool { + let scan_node = self.active_scans.iter().find_map(|(&scan_node, state)| { + self.func_defns + .get(&state.func_defn_node) + .filter(|info| info.cfg_node == Some(cfg_node)) + .map(|_| scan_node) + }); + let Some(scan_node) = scan_node else { + return false; + }; + self.advance_scan(hugr, scan_node); + true + } + + /// One element's frame finished: collect its outputs, then fold the + /// next element or complete the scan. Iterative: a pure-passthrough + /// frame completes each launch instantly, and recursing per element + /// would grow the stack with the array length. + fn advance_scan(&mut self, hugr: &Hugr, scan_node: Node) { + loop { + let Some(state) = self.active_scans.get(&scan_node) else { + return; + }; + let Some(func_info) = self.func_defns.get(&state.func_defn_node).cloned() else { + return; + }; + + // Collect the frame's outputs: port 0 = mapped element, 1.. = accs. + let read_output = |engine: &Self, port: usize| -> Option { + let (src, sp) = + hugr.single_linked_output(func_info.output_node, IncomingPort::from(port))?; + let wire = (src, sp.index()); + if let Some(value) = engine.wire_state.classical_values.get(&wire) { + return Some(value.clone()); + } + engine + .wire_state + .wire_to_qubit + .get(&wire) + .map(|q| ClassicalValue::QubitRef(*q)) + }; + let Some(mapped) = read_output(self, 0) else { + // The frame completed without producing the mapped value: a + // wiring bug this must not paper over. + self.execution_error = Some(format!( + "scan {scan_node:?}: frame completed without an output value" + )); + self.active_scans.remove(&scan_node); + return; + }; + let num_accs = self.active_scans[&scan_node].accs.len(); + let mut new_accs = Vec::with_capacity(num_accs); + for i in 0..num_accs { + if let Some(value) = read_output(self, 1 + i) { + new_accs.push(value); + } else { + self.execution_error = Some(format!( + "scan {scan_node:?}: frame completed without accumulator {i}" + )); + self.active_scans.remove(&scan_node); + return; + } + } + + let state = self + .active_scans + .get_mut(&scan_node) + .expect("checked above"); + state.results.push(mapped); + state.accs = new_accs; + + if let Some(element) = state.remaining.pop_front() { + if self.launch_scan_iteration(hugr, scan_node, element) { + // Instant (passthrough) completion: fold the next element + // in this same loop. + continue; + } + return; + } + + // All elements folded: store outputs and complete the scan node. + let state = self.active_scans.remove(&scan_node).expect("present above"); + self.store_scan_outputs(&state); + self.processed.insert(scan_node); + self.deferred_nodes.remove(&scan_node); + debug!( + "scan {scan_node:?}: complete with {} results", + state.results.len() + ); + + // Same completion cascade as a Call. + self.check_case_completion(hugr, scan_node); + self.check_cfg_block_completion(hugr, scan_node); + self.check_tailloop_body_completion(hugr, scan_node); + self.try_resolve_pending_tailloops(); + self.try_resolve_pending_cfg_branches(); + self.queue_ready_successors(hugr, scan_node); + self.retry_deferred_nodes(); + + // The frame is free now: wake any Call that parked waiting for it + // (mirrors complete_func_call_if_needed). + if let Some(pending) = self.pending_func_calls.get_mut(&state.func_defn_node) + && let Some(next_call) = pending.pop_front() + { + debug!( + "FuncDefn {:?} free after scan: starting pending Call {next_call:?}", + state.func_defn_node + ); + if !self.work_queue.contains(next_call) { + self.work_queue.push_front(next_call); + } + } + return; + } + } + + /// Store the scan node's outputs: the mapped array on port 0 and the + /// final accumulators on ports 1.. . + fn store_scan_outputs(&mut self, state: &ActiveScanInfo) { + let scan_node = state.scan_node; + self.wire_state + .classical_values + .insert((scan_node, 0), ClassicalValue::Array(state.results.clone())); + for (i, acc) in state.accs.iter().enumerate() { + self.wire_state + .classical_values + .insert((scan_node, 1 + i), acc.clone()); + } + } +} diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 2d9649e7a..ac710fe8d 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -39,10 +39,14 @@ use tket::hugr::ops::OpType; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; -use crate::engine::analysis::all_predecessors_ready; +use crate::engine::activation::{ContainerActivation, QueuePolicy}; use crate::engine::types::{ActiveTailLoopInfo, TailLoopInfo}; impl HugrEngine { + /// Iteration ceiling for `TailLoop`s: a loop that never breaks would + /// otherwise spin the processing loop forever with no yield. + const MAX_LOOP_ITERATIONS: usize = 10_000_000; + /// Try to resolve the control value for a `TailLoop`'s current iteration. /// Returns `Some(0)` for `CONTINUE_TAG` (continue looping) or `Some(1)` for `BREAK_TAG` (exit loop). pub(crate) fn try_resolve_tailloop_control( @@ -122,16 +126,71 @@ impl HugrEngine { /// Expand a `TailLoop` by activating its body for the first iteration. /// Returns the entry nodes that should be added to the work queue. - pub(crate) fn expand_tailloop(&mut self, hugr: &Hugr, tailloop_node: Node) -> Vec { + pub(crate) fn expand_tailloop(&mut self, hugr: &Hugr, tailloop_node: Node) { let Some(tailloop_info) = self.tailloops.get(&tailloop_node).cloned() else { debug!("TailLoop {tailloop_node:?} not found in tailloops map"); - return Vec::new(); + return; }; debug!("Expanding TailLoop {tailloop_node:?} for iteration 0"); - // Propagate input wires from TailLoop inputs to body Input node outputs + self.executed_containers + .insert(tailloop_node, "TailLoop body"); + + // Two-phase discipline via the shared activation mechanism (parity + // with continue_tailloop_iteration and CFG/case re-activation): all + // processed flags and stale output wires clear BEFORE any readiness + // check. A TailLoop nested in a re-expanded Case/CFG otherwise + // inherits the previous execution's state and its consumers pass + // readiness against stale flags. Queueing also releases every + // container gate (a body node may carry OUTER gates too -- a + // TailLoop inside a CFG block leaves its internals in + // nodes_inside_cfg_blocks): entry pushes bypass the gates, but the + // retry path (queue_ready_successors) checks all of them. + let mut act = ContainerActivation::new(); + for &op_node in tailloop_info + .quantum_ops + .iter() + .chain(&tailloop_info.call_nodes) + .chain(&tailloop_info.extension_ops) + .chain(&tailloop_info.classical_ops) + .chain(&tailloop_info.bool_ops) + { + act.reset(op_node); + act.queue(op_node, QueuePolicy::IfReady); + } + // Conditionals defer internally until their control resolves. + for &cond_node in &tailloop_info.conditional_nodes { + act.reset(cond_node); + act.queue(cond_node, QueuePolicy::Always); + } + // Nested containers (TailLoop-in-TailLoop, CFG-in-body): nothing + // else queues them once the body is gated. Loops defer internally + // until their inputs resolve; CFGs copy their inputs one-shot at + // activation, so they must wait for readiness like Calls do. + for &nested in &tailloop_info.tailloop_nodes { + act.reset(nested); + act.queue(nested, QueuePolicy::Always); + } + for &nested in &tailloop_info.cfg_nodes { + act.reset(nested); + act.queue(nested, QueuePolicy::IfReady); + } + // Body LoadConstants are gated behind nodes_inside_tailloops (no + // other path queues them) while readiness gates their consumers on + // the processed flag, so skipping them starves the body's classical + // dataflow. + for child in hugr.children(tailloop_node) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + act.reset_processed(child); + act.queue(child, QueuePolicy::Always); + } + } + + // Propagate input wires from TailLoop inputs to body Input node + // outputs, then apply the batch (reset -> ungate -> queue). self.propagate_tailloop_inputs(hugr, tailloop_node, &tailloop_info, 0); + self.run_activation(hugr, &act); // Register as active TailLoop self.active_tailloops.insert( @@ -143,95 +202,7 @@ impl HugrEngine { }, ); - // Activate quantum ops in the body - let mut entry_nodes = Vec::new(); - for &op_node in &tailloop_info.quantum_ops { - self.nodes_inside_tailloops.remove(&op_node); - let preds_ready = all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ); - if preds_ready { - entry_nodes.push(op_node); - } - } - - // Also activate Call nodes - for &call_node in &tailloop_info.call_nodes { - self.nodes_inside_tailloops.remove(&call_node); - if all_predecessors_ready( - hugr, - call_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) { - entry_nodes.push(call_node); - } - } - - // Also activate extension ops - for &op_node in &tailloop_info.extension_ops { - self.nodes_inside_tailloops.remove(&op_node); - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) { - entry_nodes.push(op_node); - } - } - - // Also activate classical ops - for &op_node in &tailloop_info.classical_ops { - self.nodes_inside_tailloops.remove(&op_node); - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) { - entry_nodes.push(op_node); - } - } - - // Also activate bool ops - for &op_node in &tailloop_info.bool_ops { - self.nodes_inside_tailloops.remove(&op_node); - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) { - entry_nodes.push(op_node); - } - } - - // Also activate Conditional nodes - for &cond_node in &tailloop_info.conditional_nodes { - self.nodes_inside_tailloops.remove(&cond_node); - entry_nodes.push(cond_node); - } - - debug!( - "TailLoop {tailloop_node:?}: activated body with {} entry nodes", - entry_nodes.len() - ); - - entry_nodes + debug!("TailLoop {tailloop_node:?}: activated body"); } /// Propagate wire mappings from `TailLoop` inputs to body Input node. @@ -245,33 +216,70 @@ impl HugrEngine { let input_node = tailloop_info.input_node; if iteration == 0 { - // First iteration: inputs come from TailLoop's external inputs + // First iteration: inputs come from TailLoop's external inputs, + // read through the tracing layer (an initial value produced + // inside a flattened DFG must resolve like any other read). + // Clear each port first: a loop re-expanded inside a re-run + // frame must not keep the PREVIOUS execution's final-iteration + // value on a port whose source is not ready yet (the same + // stale-argument class fixed for Call inputs). Late values are + // repaired fill-only after measurement rounds. for port_idx in 0..tailloop_info.num_inputs { - let tailloop_in_port = IncomingPort::from(port_idx); - if let Some((src_node, src_port)) = - hugr.single_linked_output(tailloop_node, tailloop_in_port) - { - let src_wire = (src_node, src_port.index()); - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - self.wire_state - .wire_to_qubit - .insert((input_node, port_idx), qubit_id); - debug!( - "TailLoop {tailloop_node:?} iter {iteration}: propagated qubit {qubit_id:?} to Input port {port_idx}" - ); - } - // Also propagate classical values - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { - self.wire_state - .classical_values - .insert((input_node, port_idx), value); - } + let input_wire = (input_node, port_idx); + self.wire_state.classical_values.remove(&input_wire); + self.wire_state.wire_to_qubit.remove(&input_wire); + if let Some(qubit_id) = self.get_input_qubit(hugr, tailloop_node, port_idx) { + self.wire_state.wire_to_qubit.insert(input_wire, qubit_id); + debug!( + "TailLoop {tailloop_node:?} iter {iteration}: propagated qubit {qubit_id:?} to Input port {port_idx}" + ); + } + if let Some(value) = self.get_input_value(hugr, tailloop_node, port_idx) { + self.wire_state.classical_values.insert(input_wire, value); } } } // For subsequent iterations, propagate_continue_values handles this } + /// Fill-only repair of active tail loops still on their FIRST + /// iteration: an initial value that was a measurement result in flight + /// at expansion left its body-Input port cleared. Called after each + /// measurement round; never touches a loop past its first Continue + /// (those ports belong to continue propagation) and never overwrites a + /// live port. + pub(crate) fn repropagate_tailloop_initial_inputs(&mut self, hugr: &Hugr) { + let targets: Vec<(Node, Node, usize)> = self + .active_tailloops + .iter() + .filter(|(_, active)| active.iteration == 0) + .filter_map(|(&tailloop_node, _)| { + self.tailloops + .get(&tailloop_node) + .map(|info| (tailloop_node, info.input_node, info.num_inputs)) + }) + .collect(); + for (tailloop_node, input_node, num_inputs) in targets { + for port_idx in 0..num_inputs { + let input_wire = (input_node, port_idx); + if self.wire_state.classical_values.contains_key(&input_wire) + || self.wire_state.wire_to_qubit.contains_key(&input_wire) + { + continue; + } + if let Some(qubit_id) = self.get_input_qubit(hugr, tailloop_node, port_idx) { + self.wire_state.wire_to_qubit.insert(input_wire, qubit_id); + } + if let Some(value) = self.get_input_value(hugr, tailloop_node, port_idx) { + debug!( + "TailLoop {tailloop_node:?}: late initial input {port_idx} repaired with {value:?}" + ); + self.wire_state.classical_values.insert(input_wire, value); + } + } + } + } + /// Continue a `TailLoop` with a new iteration after receiving `CONTINUE_TAG`. #[allow(clippy::too_many_lines)] // Loop iteration control flow is inherently complex pub(crate) fn continue_tailloop_iteration(&mut self, hugr: &Hugr, tailloop_node: Node) { @@ -287,28 +295,63 @@ impl HugrEngine { debug!("TailLoop {tailloop_node:?}: continuing to iteration {new_iteration}"); - // Clear processed state for body nodes so they can be re-executed - for &op_node in &tailloop_info.quantum_ops { - self.processed.remove(&op_node); + // Safety ceiling: a loop whose control never breaks (a program bug, + // or an engine bug feeding a stale control value) would otherwise + // spin forever inside the processing loop with no yield -- the batch + // cap counts only quantum ops, so a purely classical loop hangs the + // host process. + if new_iteration > Self::MAX_LOOP_ITERATIONS { + self.execution_error = Some(format!( + "TailLoop {tailloop_node:?} exceeded {} iterations without breaking", + Self::MAX_LOOP_ITERATIONS + )); + return; } - for &call_node in &tailloop_info.call_nodes { - self.processed.remove(&call_node); + + // Propagate iteration values from Output to Input. This reads the + // PREVIOUS iteration's body-output wires, so it must happen before + // the batch below clears them. (It touches only wire maps, never + // the processed set, so running it first is order-equivalent.) + self.propagate_continue_values(hugr, tailloop_node, &tailloop_info); + + // Re-activate the body through the shared two-phase mechanism: + // clear processed flags for every tracked body op (and body + // LoadConstants -- readiness gates consumers on their processed + // flag and no other path queues them once the body is gated) and + // stale wires for every child except the Input node, which holds + // the fresh continue values; then queue. + let mut act = ContainerActivation::new(); + for &op_node in tailloop_info + .quantum_ops + .iter() + .chain(&tailloop_info.call_nodes) + .chain(&tailloop_info.extension_ops) + .chain(&tailloop_info.classical_ops) + .chain(&tailloop_info.bool_ops) + { + act.reset_processed(op_node); + act.queue(op_node, QueuePolicy::IfReady); } - for &op_node in &tailloop_info.extension_ops { - self.processed.remove(&op_node); + for &cond_node in &tailloop_info.conditional_nodes { + act.reset_processed(cond_node); + act.queue(cond_node, QueuePolicy::Always); } - for &op_node in &tailloop_info.classical_ops { - self.processed.remove(&op_node); + for &nested in &tailloop_info.tailloop_nodes { + act.reset_processed(nested); + act.queue(nested, QueuePolicy::Always); } - for &op_node in &tailloop_info.bool_ops { - self.processed.remove(&op_node); + for &nested in &tailloop_info.cfg_nodes { + act.reset_processed(nested); + act.queue(nested, QueuePolicy::IfReady); } - for &cond_node in &tailloop_info.conditional_nodes { - self.processed.remove(&cond_node); + for child in hugr.children(tailloop_node) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + act.reset_processed(child); + act.queue(child, QueuePolicy::Always); + } + act.reset_wires(child); } - - // Propagate iteration values from Output to Input - self.propagate_continue_values(hugr, tailloop_node, &tailloop_info); + act.keep_wires(tailloop_info.input_node); // Update iteration counter if let Some(active_info) = self.active_tailloops.get_mut(&tailloop_node) { @@ -316,77 +359,7 @@ impl HugrEngine { active_info.body_active = true; } - // Re-activate body operations - for &op_node in &tailloop_info.quantum_ops { - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - for &call_node in &tailloop_info.call_nodes { - if all_predecessors_ready( - hugr, - call_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&call_node) - { - self.work_queue.push_back(call_node); - } - } - for &op_node in &tailloop_info.extension_ops { - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - for &op_node in &tailloop_info.classical_ops { - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - for &op_node in &tailloop_info.bool_ops { - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - for &cond_node in &tailloop_info.conditional_nodes { - if !self.work_queue.contains(&cond_node) { - self.work_queue.push_back(cond_node); - } - } + self.run_activation(hugr, &act); } /// Propagate values from CONTINUE tag to next iteration's inputs. @@ -405,6 +378,21 @@ impl HugrEngine { let just_inputs_count = tailloop_info.just_inputs_count; + // Clear EVERY Input port before writing fresh values: the + // activation batch exempts the Input node from wire clearing + // (keep_wires) so the fresh values survive, which means any port + // this propagation cannot source would otherwise silently keep the + // PREVIOUS iteration's value. Missing sources must starve loudly + // instead (same discipline as case-input propagation). + for port_idx in 0..(just_inputs_count + tailloop_info.rest_count) { + self.wire_state + .classical_values + .remove(&(input_node, port_idx)); + self.wire_state + .wire_to_qubit + .remove(&(input_node, port_idx)); + } + // Propagate the "rest" values from Output ports 1.. to Input ports (after just_inputs) for rest_idx in 0..tailloop_info.rest_count { let output_port_idx = rest_idx + 1; // Skip Sum port @@ -432,9 +420,38 @@ impl HugrEngine { } } - // The just_inputs values come from unpacking the Sum (CONTINUE variant) - // Trace through the Tag node that created the Sum + // The just_inputs values come from unpacking the Sum (CONTINUE variant). + // If the control wire carries an executed Sum VALUE (e.g. built by a + // Tag inside a Conditional case and routed through the Conditional's + // output), its payload elements ARE the next iteration's just_inputs. let control_port = IncomingPort::from(0); + if let Some((ctrl_src, ctrl_port)) = hugr.single_linked_output(output_node, control_port) + && let Some(crate::engine::types::ClassicalValue::Sum { tag: 0, values }) = self + .wire_state + .classical_values + .get(&(ctrl_src, ctrl_port.index())) + .cloned() + { + for (port_idx, value) in values.into_iter().enumerate() { + if port_idx >= just_inputs_count { + break; + } + debug!( + "TailLoop continue: propagated just_input value {value:?} to Input:{port_idx}" + ); + if let crate::engine::types::ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((input_node, port_idx), *qubit_id); + } + self.wire_state + .classical_values + .insert((input_node, port_idx), value); + } + return; + } + + // Otherwise trace through the Tag node that created the Sum structurally if let Some((tag_node, _)) = hugr.single_linked_output(output_node, control_port) && let OpType::Tag(tag_op) = hugr.get_optype(tag_node) && tag_op.tag == 0 @@ -454,6 +471,9 @@ impl HugrEngine { ); } if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { + debug!( + "TailLoop continue: propagated just_input value {value:?} to Input:{port_idx}" + ); self.wire_state .classical_values .insert((input_node, port_idx), value); @@ -483,7 +503,14 @@ impl HugrEngine { self.queue_ready_successors(hugr, tailloop_node); // Check if this TailLoop completion allows a CFG block to complete + // A parent Case whose final completion event is this loop must + // observe it too. + self.check_scan_frame_completion(hugr, tailloop_node); + self.check_case_completion(hugr, tailloop_node); self.check_cfg_block_completion(hugr, tailloop_node); + // A completed loop may be the last op of an ENCLOSING loop's body + // (TailLoop-in-TailLoop). + self.check_tailloop_body_completion(hugr, tailloop_node); } /// Propagate outputs from `TailLoop` body to `TailLoop` node outputs. @@ -530,8 +557,38 @@ impl HugrEngine { } } - // Extract just_outputs from BREAK Sum variant (tag 1) + // Extract just_outputs from BREAK Sum variant (tag 1). If the + // control wire carries an executed Sum VALUE (e.g. routed through a + // Conditional's output rather than a direct Tag node), its payload + // elements ARE the just_outputs. let control_port = IncomingPort::from(0); + if let Some((ctrl_src, ctrl_port)) = hugr.single_linked_output(output_node, control_port) + && let Some(crate::engine::types::ClassicalValue::Sum { tag: 1, values }) = self + .wire_state + .classical_values + .get(&(ctrl_src, ctrl_port.index())) + .cloned() + { + for (port_idx, value) in values.into_iter().enumerate() { + if port_idx >= just_outputs_count { + break; + } + debug!( + "TailLoop {tailloop_node:?} output {port_idx}: mapped just_output payload {value:?}" + ); + if let crate::engine::types::ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((tailloop_node, port_idx), *qubit_id); + } + self.wire_state + .classical_values + .insert((tailloop_node, port_idx), value); + } + return; + } + + // Otherwise trace the BREAK Tag structurally if let Some((tag_node, _)) = hugr.single_linked_output(output_node, control_port) && let OpType::Tag(tag_op) = hugr.get_optype(tag_node) && tag_op.tag == 1 @@ -584,7 +641,9 @@ impl HugrEngine { || tailloop_info.extension_ops.contains(&processed_node) || tailloop_info.classical_ops.contains(&processed_node) || tailloop_info.bool_ops.contains(&processed_node) - || tailloop_info.conditional_nodes.contains(&processed_node); + || tailloop_info.conditional_nodes.contains(&processed_node) + || tailloop_info.tailloop_nodes.contains(&processed_node) + || tailloop_info.cfg_nodes.contains(&processed_node); if is_in_loop { // Check if all ops are processed @@ -608,10 +667,22 @@ impl HugrEngine { .bool_ops .iter() .all(|op| self.processed.contains(op)); + // A Conditional is marked processed at EXPANSION; its + // outputs exist only once its selected case completes, so + // an active case must also block body completion. let all_conditionals_done = tailloop_info .conditional_nodes .iter() - .all(|cond| self.processed.contains(cond)); + .all(|cond| self.node_settled(*cond)); + + // Nested containers count as done only once processed AND + // no longer active (a nested loop marks processed at its + // own completion; a nested CFG at complete_cfg_execution). + let all_nested_done = tailloop_info + .tailloop_nodes + .iter() + .chain(&tailloop_info.cfg_nodes) + .all(|nested| self.node_settled(*nested)); if all_quantum_done && all_calls_done @@ -619,6 +690,7 @@ impl HugrEngine { && all_classical_done && all_bool_done && all_conditionals_done + && all_nested_done { completions.push(*tailloop_node); } @@ -635,7 +707,14 @@ impl HugrEngine { // Try to resolve control immediately if let Some(tag) = self.try_resolve_tailloop_control(hugr, tailloop_node) { - if tag == 0 { + if tag > 1 { + // The control Sum has exactly two variants (0=continue, + // 1=break); anything else is an upstream propagation + // bug that must not complete cleanly as a break. + self.execution_error = Some(format!( + "TailLoop {tailloop_node:?}: control tag {tag} out of range" + )); + } else if tag == 0 { // CONTINUE self.continue_tailloop_iteration(hugr, tailloop_node); } else { @@ -646,7 +725,7 @@ impl HugrEngine { // Add to pending self.pending_tailloop_control.insert(tailloop_node); // Re-add to work queue for resolution after measurements - if !self.work_queue.contains(&tailloop_node) { + if !self.work_queue.contains(tailloop_node) { self.work_queue.push_back(tailloop_node); } } diff --git a/crates/pecos-hugr/src/engine/handlers.rs b/crates/pecos-hugr/src/engine/handlers.rs index e36eff453..1c113444c 100644 --- a/crates/pecos-hugr/src/engine/handlers.rs +++ b/crates/pecos-hugr/src/engine/handlers.rs @@ -63,6 +63,37 @@ use tket::hugr::{Hugr, HugrView, Node}; use super::HugrEngine; +/// Outcome of an extension-op handler. +/// +/// This replaces the old boolean contract (`true` = handled, `false` = +/// defer) so that fatal faults are a first-class result instead of a side +/// channel: a handler that cannot ever produce a value must not choose +/// between fabricating one and silently deferring forever. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum HandlerOutcome { + /// The op executed; its outputs (if any) are stored on its wires. + Processed, + /// Inputs not ready, or the op is unknown to the engine: park the node + /// for retry. A node that defers forever surfaces in the stall report. + Defer, + /// Fatal fault: poison the execution with this message. + Fault(String), +} + +/// Outcome of the classical-op executor ([`HugrEngine::handle_classical_op`]). +/// +/// Same three-way contract as [`HandlerOutcome`], but `Processed` carries +/// the computed `(output_port, value)` pairs for the caller to store. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum ClassicalOutcome { + /// The op executed; store these `(port, value)` pairs on its wires. + Outputs(Vec<(usize, crate::engine::types::ClassicalValue)>), + /// Inputs not ready or unconvertible: park the node for retry. + Defer, + /// Fatal fault: poison the execution with this message. + Fault(String), +} + impl HugrEngine { /// Handle extension operations from various tket extensions. /// @@ -94,11 +125,13 @@ impl HugrEngine { /// /// # Returns /// - /// Returns `true` if the operation was handled, `false` otherwise. - pub(crate) fn handle_extension_op(&mut self, hugr: &Hugr, node: Node) -> bool { + /// Returns the [`HandlerOutcome`]: `Processed` when the op executed, + /// `Defer` when its inputs are not ready yet or the op is unknown (the + /// caller parks such nodes for retry), `Fault` for a fatal fault. + pub(crate) fn handle_extension_op(&mut self, hugr: &Hugr, node: Node) -> HandlerOutcome { let op = hugr.get_optype(node); let Some(ext_op) = op.as_extension_op() else { - return false; + return HandlerOutcome::Defer; }; let ext_id = ext_op.extension_id(); @@ -114,7 +147,7 @@ impl HugrEngine { "tket.debug" => self.handle_debug_op(hugr, node, &op_name), "tket.bool" => self.handle_bool_op(hugr, node, &op_name), "tket.rotation" => self.handle_rotation_op(hugr, node, &op_name), - "tket.modifier" => self.handle_modifier_op(hugr, node, &op_name), + "tket.modifier" => Self::handle_modifier_op(node, &op_name), "tket.wasm" => self.handle_wasm_op(hugr, node, &op_name), "tket.guppy" => self.handle_guppy_op(hugr, node, &op_name), "tket.global_phase" => self.handle_global_phase_op(hugr, node, &op_name), @@ -126,7 +159,7 @@ impl HugrEngine { "arithmetic.float" => self.handle_float_op(hugr, node, &op_name), "arithmetic.int" => self.handle_int_op(hugr, node, &op_name), "arithmetic.conversions" => self.handle_conversions_op(hugr, node, &op_name), - _ => false, + _ => HandlerOutcome::Defer, } } } diff --git a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs index e9a202c09..872a63adf 100644 --- a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs +++ b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs @@ -20,15 +20,21 @@ //! - Conversions: int<->float type conversions use log::debug; -use tket::hugr::{Hugr, Node}; +use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { /// Handle `arithmetic.float` operations (transcendental functions, etc.). #[allow(clippy::too_many_lines)] - pub(crate) fn handle_float_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_float_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing arithmetic.float operation: {op_name} at {node:?}"); // Get input values @@ -103,35 +109,27 @@ impl HugrEngine { a.zip(b).zip(c).map(|((x, y), z)| x.mul_add(y, z)) } - // Float comparisons - exact comparison is intentional per HUGR semantics - #[allow(clippy::float_cmp)] - "feq" => a.zip(b).map(|(x, y)| if x == y { 1.0 } else { 0.0 }), - #[allow(clippy::float_cmp)] - "fne" => a.zip(b).map(|(x, y)| if x == y { 0.0 } else { 1.0 }), - "flt" => a.zip(b).map(|(x, y)| if x < y { 1.0 } else { 0.0 }), - "fle" => a.zip(b).map(|(x, y)| if x <= y { 1.0 } else { 0.0 }), - "fgt" => a.zip(b).map(|(x, y)| if x > y { 1.0 } else { 0.0 }), - "fge" => a.zip(b).map(|(x, y)| if x >= y { 1.0 } else { 0.0 }), - - // Check for special values - "fis_nan" | "is_nan" => a.map(|x| if x.is_nan() { 1.0 } else { 0.0 }), - "fis_inf" | "is_inf" => a.map(|x| if x.is_infinite() { 1.0 } else { 0.0 }), - "fis_finite" | "is_finite" => a.map(|x| if x.is_finite() { 1.0 } else { 0.0 }), - + // Float comparisons (feq/fne/flt/fle/fgt/fge) are CLASSIFIED + // ops handled by the classical path with proper Bool outputs; + // duplicate arms here used to return Float(1.0/0.0) -- deleted + // so classification drift cannot resurrect the wrong type. _ => { debug!("Unknown arithmetic.float operation: {op_name}"); - return false; + return HandlerOutcome::Defer; } }; - if let Some(value) = result { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Float(value)); - debug!("arithmetic.float.{op_name}: result = {value}"); - } - - true + // A missing or unconvertible input defers the op: marking it + // processed with no output would strand every consumer. + let Some(value) = result else { + debug!("arithmetic.float.{op_name} at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Float(value)); + debug!("arithmetic.float.{op_name}: result = {value}"); + HandlerOutcome::Processed } /// Handle `arithmetic.int` operations (extended integer operations). @@ -140,165 +138,237 @@ impl HugrEngine { clippy::cast_sign_loss, // shift amounts are clamped to 0-63 before cast to u32 clippy::cast_possible_truncation // shift amounts are clamped before cast )] - pub(crate) fn handle_int_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_int_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing arithmetic.int operation: {op_name} at {node:?}"); - // Get input values + // Ops with a classify_classical_op entry (add/sub/mul/div/mod/ + // comparisons/bitwise/shifts and their checked variants) never reach + // this handler -- they execute through the classical-op path. Only + // the UNCLASSIFIED arithmetic.int ops live here; keeping shadowed + // duplicate arms around invited semantic drift (they had truncating + // division while the classical path is Euclidean), so they were + // removed. + + // Get input values. The op's width (BoundedNat type arg) makes the + // bit-level ops exact for narrow ints; absent args default to 64. + let log_width = hugr + .get_optype(node) + .as_extension_op() + .and_then(|ext| { + ext.args().iter().find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => u8::try_from(*n).ok(), + _ => None, + }) + }) + .unwrap_or(6); + let bits = u64::from(1u32 << log_width); + let mask = crate::engine::handlers::classical::width_mask(log_width); + let canon = |v: i64| crate::engine::handlers::classical::canonicalize_width(v, log_width); let a = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()); let b = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()); + // Unsigned reads must reinterpret the canonical (sign-extended) + // bit pattern at the op's width -- as_uint rejects negatives, so a + // canonical int<1> "1" (stored -1) would defer forever. + #[allow(clippy::cast_sign_loss)] + let read_unsigned = |engine: &Self, port: usize| -> Option { + engine + .get_input_value(hugr, node, port) + .and_then(|v| v.as_int()) + .map(|v| (v as u64) & mask) + }; + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] let result: Option = match op_name { - // Basic arithmetic (may also be handled elsewhere) - "iadd" => a.zip(b).map(|(x, y)| x.wrapping_add(y)), - "isub" => a.zip(b).map(|(x, y)| x.wrapping_sub(y)), - "imul" => a.zip(b).map(|(x, y)| x.wrapping_mul(y)), - "idiv_s" | "idiv" => a.zip(b).map(|(x, y)| if y != 0 { x / y } else { 0 }), - // Cast u64 result to i64 for unified storage - wrap is acceptable for large values - #[allow(clippy::cast_possible_wrap)] - "idiv_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu) - .map(|(x, y)| x.checked_div(y).map_or(0, |q| q as i64)) - } - "imod_s" | "imod" => a.zip(b).map(|(x, y)| if y != 0 { x % y } else { 0 }), - #[allow(clippy::cast_possible_wrap)] - "imod_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu) - .map(|(x, y)| if y != 0 { (x % y) as i64 } else { 0 }) - } - "ineg" => a.map(i64::wrapping_neg), - "iabs" => a.map(i64::abs), - - // Bitwise operations - "iand" => a.zip(b).map(|(x, y)| x & y), - "ior" => a.zip(b).map(|(x, y)| x | y), - "ixor" => a.zip(b).map(|(x, y)| x ^ y), - "inot" => a.map(|x| !x), - - // Shift operations - clamp shift amount to valid range (0-63 for i64) - "ishl" => a.zip(b).map(|(x, y)| x.wrapping_shl(y.clamp(0, 63) as u32)), - "ishr_s" | "ishr" => a.zip(b).map(|(x, y)| x.wrapping_shr(y.clamp(0, 63) as u32)), - #[allow(clippy::cast_possible_wrap)] - "ishr_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - au.zip(b).map(|(x, y)| (x >> y.clamp(0, 63) as u32) as i64) - } - "irotl" | "rotl" => a.zip(b).map(|(x, y)| x.rotate_left(y.clamp(0, 63) as u32)), - "irotr" | "rotr" => a.zip(b).map(|(x, y)| x.rotate_right(y.clamp(0, 63) as u32)), - - // Bit counting - "ipopcnt" | "popcnt" | "popcount" => a.map(|x| i64::from(x.count_ones())), - "iclz" | "clz" => a.map(|x| i64::from(x.leading_zeros())), - "ictz" | "ctz" => a.map(|x| i64::from(x.trailing_zeros())), + // Rotations: the amount is reduced modulo the width, per the + // spec's const-fold (`k = n1 % w`); the rotation happens within + // the op's N bits. + "irotl" | "rotl" => a.zip(b).map(|(x, y)| { + let k = (y as u64) % bits; + let x = (x as u64) & mask; + let rotated = if k == 0 { + x + } else { + ((x << k) | (x >> (bits - k))) & mask + }; + canon(rotated.cast_signed()) + }), + "irotr" | "rotr" => a.zip(b).map(|(x, y)| { + let k = (y as u64) % bits; + let x = (x as u64) & mask; + let rotated = if k == 0 { + x + } else { + ((x >> k) | (x << (bits - k))) & mask + }; + canon(rotated.cast_signed()) + }), // Min/max "imin_s" | "imin" => a.zip(b).map(|(x, y)| x.min(y)), "imax_s" | "imax" => a.zip(b).map(|(x, y)| x.max(y)), #[allow(clippy::cast_possible_wrap)] "imin_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| x.min(y) as i64) + let au = read_unsigned(self, 0); + let bu = read_unsigned(self, 1); + au.zip(bu).map(|(x, y)| canon(x.min(y) as i64)) } #[allow(clippy::cast_possible_wrap)] "imax_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| x.max(y) as i64) + let au = read_unsigned(self, 0); + let bu = read_unsigned(self, 1); + au.zip(bu).map(|(x, y)| canon(x.max(y) as i64)) } - // Sign extension / truncation - all no-ops for i64 unified storage - #[allow(clippy::match_same_arms)] // Intentionally separate for clarity - "iwiden_s" | "widen_s" => a, // Sign-extend (no-op for i64) - #[allow(clippy::cast_possible_wrap)] - "iwiden_u" | "widen_u" => self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()) - .map(|x| x as i64), - #[allow(clippy::match_same_arms)] - "inarrow_s" | "narrow_s" => a, // Truncate (no-op for now) + // Width widening. Signed values are stored canonically + // sign-extended, so widen_s is the identity; widen_u must + // ZERO-extend from the source width (the first BoundedNat arg, + // which is what `mask` derives from), e.g. a canonical 1-bit + // "1" is stored as -1 and widens to 1. The zero-extended value + // is already canonical at the (wider) destination. #[allow(clippy::match_same_arms)] - "inarrow_u" | "narrow_u" => a, // Truncate (no-op for now) - - // Comparisons (return 0 or 1) - "ieq" => a.zip(b).map(|(x, y)| i64::from(x == y)), - "ine" => a.zip(b).map(|(x, y)| i64::from(x != y)), - "ilt_s" | "ilt" => a.zip(b).map(|(x, y)| i64::from(x < y)), - "ile_s" | "ile" => a.zip(b).map(|(x, y)| i64::from(x <= y)), - "igt_s" | "igt" => a.zip(b).map(|(x, y)| i64::from(x > y)), - "ige_s" | "ige" => a.zip(b).map(|(x, y)| i64::from(x >= y)), - "ilt_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| i64::from(x < y)) - } - "ile_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| i64::from(x <= y)) - } - "igt_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| i64::from(x > y)) - } - "ige_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| i64::from(x >= y)) + "iwiden_s" | "widen_s" => a, + #[allow(clippy::cast_possible_wrap)] + "iwiden_u" | "widen_u" => read_unsigned(self, 0).map(|x| x as i64), + + // Width narrowing returns sum_with_error(int): handled below + // (multi-variant output, not a plain i64). + "inarrow_s" | "narrow_s" | "inarrow_u" | "narrow_u" => { + return self.handle_inarrow(hugr, node, op_name); } + // Signedness reinterpretation. The spec PANICS out of range + // (iu_to_s of an unsigned value >= 2^(N-1); is_to_u of a + // negative) -- silently passing the bit pattern through would + // hand consumers a sign-flipped value. + "iu_to_s" => match read_unsigned(self, 0) { + None => None, + Some(value) => { + let out_of_range = if log_width < 6 { + value >= (1u64 << ((1u32 << log_width) - 1)) + } else { + value > i64::MAX as u64 + }; + if out_of_range { + return HandlerOutcome::Fault(format!( + "iu_to_s at {node:?}: unsigned value {value} does not fit \ + the signed range at width 2^{log_width} (the spec panics)" + )); + } + Some(canon(value.cast_signed())) + } + }, + "is_to_u" => match a { + None => None, + Some(value) => { + if canon(value) < 0 { + return HandlerOutcome::Fault(format!( + "is_to_u at {node:?}: negative value {value} has no \ + unsigned interpretation (the spec panics)" + )); + } + Some(canon(value)) + } + }, + _ => { debug!("Unknown arithmetic.int operation: {op_name}"); - return false; + return HandlerOutcome::Defer; } }; - if let Some(value) = result { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(value)); - debug!("arithmetic.int.{op_name}: result = {value}"); - } + // A missing or unconvertible input defers the op: marking it + // processed with no output would strand every consumer (the node is + // never retried once processed). + let Some(value) = result else { + debug!("arithmetic.int.{op_name} at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(value)); + debug!("arithmetic.int.{op_name}: result = {value}"); + HandlerOutcome::Processed + } - true + /// `inarrow_s`/`inarrow_u`: narrow to width `N`, returning + /// `sum_with_error(int)` -- error variant (tag 0) when the value does + /// not fit, value variant (tag 1) otherwise. + fn handle_inarrow(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> HandlerOutcome { + let signed = !op_name.ends_with('u'); + // Target log-width is the SECOND type arg (source is the first). + let target_log_width = hugr + .get_optype(node) + .as_extension_op() + .and_then(|ext| { + ext.args() + .iter() + .filter_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => Some(*n), + _ => None, + }) + .nth(1) + }) + .unwrap_or(6); + // Source log-width is the FIRST type arg: the unsigned range test + // must reinterpret the canonical (sign-extended) storage as the + // SOURCE-width bit pattern, or a high-bit unsigned value (stored + // negative) would spuriously fail to fit. + let source_log_width = hugr + .get_optype(node) + .as_extension_op() + .and_then(|ext| { + ext.args().iter().find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => u8::try_from(*n).ok(), + _ => None, + }) + }) + .unwrap_or(6); + let bits = 1u32 << target_log_width.min(6); + let Some(v) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) else { + debug!("arithmetic.int.{op_name} at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)] + let (fits, narrowed) = if signed { + let fits = if bits >= 64 { + true + } else { + let min = -(1i64 << (bits - 1)); + let max = (1i64 << (bits - 1)) - 1; + v >= min && v <= max + }; + (fits, v) + } else { + let pattern = + (v as u64) & crate::engine::handlers::classical::width_mask(source_log_width); + let fits = bits >= 64 || pattern < (1u64 << bits); + // Store canonically at the TARGET width (sign-extended). + #[allow(clippy::cast_possible_truncation)] + let narrowed = crate::engine::handlers::classical::canonicalize_width( + pattern as i64, + target_log_width.min(6) as u8, + ); + (fits, narrowed) + }; + let result = if fits { + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(narrowed)], + } + } else { + ClassicalValue::Sum { + tag: 0, + values: vec![ClassicalValue::Tuple(vec![])], + } + }; + debug!("arithmetic.int.{op_name}: {result:?}"); + self.wire_state.classical_values.insert((node, 0), result); + HandlerOutcome::Processed } /// Handle `arithmetic.conversions` operations (int/float conversions). @@ -313,150 +383,182 @@ impl HugrEngine { clippy::cast_possible_truncation, clippy::cast_sign_loss )] - pub(crate) fn handle_conversions_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_conversions_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing arithmetic.conversions operation: {op_name} at {node:?}"); match op_name { - // Integer to float conversions - "convert_s" | "itof_s" => { - // Signed integer to float - if let Some(value) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) { - let result = value as f64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Float(result)); - debug!("convert_s: {value} -> {result}"); - } - } - "convert_u" | "itof_u" => { - // Unsigned integer to float - if let Some(value) = self + // convert_s / convert_u are CLASSIFIED ops handled by the + // width-aware classical path; the duplicate arms here read + // via as_uint (which rejects canonical negative storage) and + // were deleted so classification drift cannot resurrect them. + + // usize <-> int conversions (e.g. guppy loop bounds built from + // prelude.load_nat's usize output) + "ifromusize" => { + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_uint()) - { - let result = value as f64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Float(result)); - debug!("convert_u: {value} -> {result}"); - } + else { + debug!("ifromusize at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + #[allow(clippy::cast_possible_wrap)] + let result = value as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("ifromusize: {value} -> {result}"); + } + "itousize" => { + let Some(value) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) + else { + debug!("itousize at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + #[allow(clippy::cast_sign_loss)] + let result = value as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("itousize: {value} -> {result}"); } // Float to integer conversions (truncate toward zero) - "trunc_s" | "ftoi_s" => { + "ftoi_s" => { // Float to signed integer (truncate) - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let result = value.trunc() as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(result)); - debug!("trunc_s: {value} -> {result}"); - } + else { + debug!("trunc_s at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + let result = value.trunc() as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("trunc_s: {value} -> {result}"); } - "trunc_u" | "ftoi_u" => { + "ftoi_u" => { // Float to unsigned integer (truncate) - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - // Clamp to non-negative before converting - let clamped = value.max(0.0).trunc(); - let result = clamped as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(result)); - debug!("trunc_u: {value} -> {result}"); - } + else { + debug!("trunc_u at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + // Clamp to non-negative before converting + let clamped = value.max(0.0).trunc(); + let result = clamped as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("trunc_u: {value} -> {result}"); } // Ceiling/floor variants "ceil_s" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let result = value.ceil() as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(result)); - debug!("ceil_s: {value} -> {result}"); - } + else { + debug!("ceil_s at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + let result = value.ceil() as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("ceil_s: {value} -> {result}"); } "ceil_u" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let clamped = value.max(0.0).ceil(); - let result = clamped as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(result)); - debug!("ceil_u: {value} -> {result}"); - } + else { + debug!("ceil_u at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + let clamped = value.max(0.0).ceil(); + let result = clamped as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("ceil_u: {value} -> {result}"); } "floor_s" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let result = value.floor() as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(result)); - debug!("floor_s: {value} -> {result}"); - } + else { + debug!("floor_s at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + let result = value.floor() as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("floor_s: {value} -> {result}"); } "floor_u" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let clamped = value.max(0.0).floor(); - let result = clamped as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(result)); - debug!("floor_u: {value} -> {result}"); - } + else { + debug!("floor_u at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + let clamped = value.max(0.0).floor(); + let result = clamped as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("floor_u: {value} -> {result}"); } // Rounding "round_s" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let result = value.round() as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(result)); - debug!("round_s: {value} -> {result}"); - } + else { + debug!("round_s at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + let result = value.round() as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("round_s: {value} -> {result}"); } "round_u" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let clamped = value.max(0.0).round(); - let result = clamped as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(result)); - debug!("round_u: {value} -> {result}"); - } + else { + debug!("round_u at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; + let clamped = value.max(0.0).round(); + let result = clamped as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("round_u: {value} -> {result}"); } _ => { debug!("Unknown arithmetic.conversions operation: {op_name}"); - return false; + return HandlerOutcome::Defer; } } - true + HandlerOutcome::Processed } } diff --git a/crates/pecos-hugr/src/engine/handlers/array.rs b/crates/pecos-hugr/src/engine/handlers/array.rs index ca04c1c6b..f7f478108 100644 --- a/crates/pecos-hugr/src/engine/handlers/array.rs +++ b/crates/pecos-hugr/src/engine/handlers/array.rs @@ -14,16 +14,28 @@ //! Array operations (`collections.array`). //! -//! This module handles array collection operations: -//! - Creation: `new_array`, `repeat` -//! - Access: `get`, `set`, `len` -//! - Modification: `push`, `pop`, `swap` +//! Implements the HUGR std array ops with their spec signatures +//! (`hugr-core` `std_extensions/collections/array/array_op.rs`): +//! - `new_array`: `[T; n] -> [array]` +//! - `unpack`: `[array] -> [T; n]` +//! - `get`: `[array, usize] -> [option, array]` +//! - `set`: `[array, usize, T] -> [either([T, array], [T, array])]` +//! (tag 1 = success carrying the displaced element; tag 0 = out-of-bounds +//! carrying the given element back so linear values are not lost) +//! - `swap`: `[array, usize, usize] -> [either([array], [array])]` +//! - `pop_left`/`pop_right`: `[array] -> [option<(T, array)>]` +//! - `discard_empty`: `[array<0, T>] -> []` +//! +//! Handlers defer (return false) on missing inputs so consumers never see +//! fabricated arrays or elements; unknown ops also defer so they surface in +//! the completion-time stall report instead of silently passing through. use log::debug; use tket::hugr::ops::OpTrait; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { @@ -32,166 +44,234 @@ impl HugrEngine { clippy::too_many_lines, clippy::cast_possible_truncation // Array indices in simulation context won't exceed usize )] - pub(crate) fn handle_array_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_array_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing collections.array operation: {op_name} at {node:?}"); match op_name { "new_array" | "NewArray" => { - // new_array: (T, ...) -> Array - // Collect all inputs into an array + // [T; n] -> [array]. A missing element defers the whole + // construction: silently skipping it would shorten the array + // and shift every index. Qubit elements ride as QubitRef. let op = hugr.get_optype(node); let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); - let mut elements = Vec::with_capacity(num_inputs); for port in 0..num_inputs { if let Some(value) = self.get_input_value(hugr, node, port) { elements.push(value); + } else if let Some(qubit_id) = self.get_input_qubit(hugr, node, port) { + elements.push(ClassicalValue::QubitRef(qubit_id)); + } else { + debug!("new_array at {node:?}: element {port} not ready, deferring"); + return HandlerOutcome::Defer; } } + debug!("new_array: created array with {} elements", elements.len()); self.wire_state .classical_values - .insert((node, 0), ClassicalValue::Array(elements.clone())); - - debug!("new_array: created array with {} elements", elements.len()); - true + .insert((node, 0), ClassicalValue::Array(elements)); + HandlerOutcome::Processed } - "get" | "Get" | "index" | "Index" => { - // get: (Array, int) -> T - // Get element at index - let array = self.get_input_value(hugr, node, 0); - let index = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; - - if let Some(ClassicalValue::Array(elements)) = array { - if let Some(element) = elements.get(index) { + "unpack" => { + // [array] -> [T; n]: each element on its own output port. + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.unpack at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + for (port, value) in elements.into_iter().enumerate() { + if let ClassicalValue::QubitRef(qubit_id) = &value { self.wire_state - .classical_values - .insert((node, 0), element.clone()); - debug!("array.get[{index}]: retrieved element"); - } else { - debug!("array.get[{index}]: index out of bounds"); + .wire_to_qubit + .insert((node, port), *qubit_id); } + self.wire_state.classical_values.insert((node, port), value); } - true + debug!("array.unpack at {node:?}: unpacked"); + HandlerOutcome::Processed } - "set" | "Set" => { - // set: (Array, int, T) -> Array - // Set element at index - let array = self.get_input_value(hugr, node, 0); - let index = self + "get" | "Get" | "index" | "Index" => { + // [array, usize] -> [option, array]: option on port 0 + // (None = tag 0 for out-of-bounds), the array back on port 1. + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.get at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; - let value = self.get_input_value(hugr, node, 2); - - if let (Some(ClassicalValue::Array(mut elements)), Some(new_value)) = (array, value) - { - if index < elements.len() { - elements[index] = new_value; - } - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.set[{index}]: updated element"); - } - true - } - "len" | "Len" | "length" | "Length" => { - // len: Array -> int - // Get array length - let array = self.get_input_value(hugr, node, 0); - - if let Some(ClassicalValue::Array(elements)) = array { - let len = elements.len() as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(len)); - debug!("array.len: {len}"); - } - true - } - "pop" | "Pop" => { - // pop: Array -> (Array, T) - // Remove and return the last element - let array = self.get_input_value(hugr, node, 0); - - if let Some(ClassicalValue::Array(mut elements)) = array - && let Some(last) = elements.pop() - { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - self.wire_state.classical_values.insert((node, 1), last); - debug!("array.pop: removed last element"); - } - true - } - "push" | "Push" => { - // push: (Array, T) -> Array - // Append element to array - let array = self.get_input_value(hugr, node, 0); - let value = self.get_input_value(hugr, node, 1); + .map(|v| v as usize) + else { + debug!("array.get at {node:?}: index not ready, deferring"); + return HandlerOutcome::Defer; + }; - if let (Some(ClassicalValue::Array(mut elements)), Some(new_value)) = (array, value) - { - elements.push(new_value); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.push: appended element"); - } - true + let result = match elements.get(index) { + Some(element) => ClassicalValue::Sum { + tag: 1, + values: vec![element.clone()], + }, + None => ClassicalValue::Sum { + tag: 0, + values: vec![], + }, + }; + debug!("array.get[{index}]: {result:?}"); + self.wire_state.classical_values.insert((node, 0), result); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Array(elements)); + HandlerOutcome::Processed } - "repeat" | "Repeat" => { - // repeat: (T, int) -> Array - // Create array with n copies of value - let value = self.get_input_value(hugr, node, 0); - let count = self + "set" | "Set" => { + // [array, usize, T] -> [either([T, array], [T, array])]: + // tag 1 = success carrying the DISPLACED element and the + // updated array; tag 0 = out-of-bounds carrying the given + // element and the unchanged array (linear values survive). + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.set at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; + .map(|v| v as usize) + else { + debug!("array.set at {node:?}: index not ready, deferring"); + return HandlerOutcome::Defer; + }; + let new_value = if let Some(qubit_id) = self.get_input_qubit(hugr, node, 2) { + ClassicalValue::QubitRef(qubit_id) + } else if let Some(value) = self.get_input_value(hugr, node, 2) { + value + } else { + debug!("array.set at {node:?}: value not ready, deferring"); + return HandlerOutcome::Defer; + }; - if let Some(val) = value { - let elements = vec![val; count]; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.repeat: created array with {count} copies"); - } - true + let result = if let Some(slot) = elements.get_mut(index) { + let displaced = std::mem::replace(slot, new_value); + debug!("array.set[{index}]: element replaced"); + ClassicalValue::Sum { + tag: 1, + values: vec![displaced, ClassicalValue::Array(elements)], + } + } else { + debug!("array.set[{index}]: out of bounds (len={})", elements.len()); + ClassicalValue::Sum { + tag: 0, + values: vec![new_value, ClassicalValue::Array(elements)], + } + }; + self.wire_state.classical_values.insert((node, 0), result); + HandlerOutcome::Processed } "swap" | "Swap" => { - // swap: (Array, int, int) -> Array - // Swap elements at two indices - let array = self.get_input_value(hugr, node, 0); - let i = self + // [array, usize, usize] -> [either([array], [array])]: + // tag 1 = success with the swapped array; tag 0 = + // out-of-bounds with the unchanged array. + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.swap at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(i) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; - let j = self + .map(|v| v as usize) + else { + debug!("array.swap at {node:?}: first index not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(j) = self .get_input_value(hugr, node, 2) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; + .map(|v| v as usize) + else { + debug!("array.swap at {node:?}: second index not ready, deferring"); + return HandlerOutcome::Defer; + }; - if let Some(ClassicalValue::Array(mut elements)) = array { - if i < elements.len() && j < elements.len() { - elements.swap(i, j); + let result = if i < elements.len() && j < elements.len() { + elements.swap(i, j); + debug!("array.swap[{i},{j}]: swapped"); + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Array(elements)], } - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.swap[{i}, {j}]"); + } else { + debug!( + "array.swap[{i},{j}]: out of bounds (len={})", + elements.len() + ); + ClassicalValue::Sum { + tag: 0, + values: vec![ClassicalValue::Array(elements)], + } + }; + self.wire_state.classical_values.insert((node, 0), result); + HandlerOutcome::Processed + } + "pop_left" | "pop_right" => { + // [array] -> [option<(T, array)>]: the empty + // variant (tag 0) when nothing is left, else (element, rest) + // in the value variant (tag 1). + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.{op_name} at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + let result = if elements.is_empty() { + debug!("array.{op_name} at {node:?}: empty array -> None variant"); + ClassicalValue::Sum { + tag: 0, + values: vec![], + } + } else { + let element = if op_name == "pop_left" { + elements.remove(0) + } else { + elements.pop().expect("non-empty checked above") + }; + debug!( + "array.{op_name} at {node:?}: popped element, {} remain", + elements.len() + ); + ClassicalValue::Sum { + tag: 1, + values: vec![element, ClassicalValue::Array(elements)], + } + }; + self.wire_state.classical_values.insert((node, 0), result); + HandlerOutcome::Processed + } + "discard_empty" => { + // [array<0, T>] -> []: consume an empty array. Defer until + // the value exists so the op is not marked done while its + // producer is pending. + if self.get_input_value(hugr, node, 0).is_none() { + debug!("array.discard_empty at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; } - true + debug!("array.discard_empty: array consumed"); + HandlerOutcome::Processed } + "scan" => self.handle_scan_op(hugr, node), _ => { - // For unknown array operations, try pass-through - debug!("Unknown collections.array operation: {op_name} - attempting pass-through"); - self.propagate_all_inputs(hugr, node); - true + // Unknown/unimplemented array op (e.g. `repeat`, whose real + // signature takes a function value the engine cannot + // execute): defer so it surfaces in the completion-time + // stall report instead of silently passing values through + // as if the op were an identity wire. + debug!("Unknown collections.array operation: {op_name} at {node:?}, deferring"); + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs index 3a574bd1e..106c1f957 100644 --- a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs +++ b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs @@ -14,205 +14,343 @@ //! Borrow array operations (`collections.borrow_arr`). //! -//! This module handles borrow-checked array operations emitted by Guppy -//! for array element access with ownership tracking. At simulation time, -//! borrow tracking is irrelevant -- we just need array access semantics. +//! This module handles borrow-checked array operations emitted by Guppy for +//! array element access with ownership tracking (`qs[i]`, array +//! comprehensions, `discard_array`). Arrays are represented as +//! [`ClassicalValue::Array`] whose slots hold element values (qubits as +//! [`ClassicalValue::QubitRef`]) or [`ClassicalValue::Borrowed`] holes. //! -//! Operations: -//! - `new_all_borrowed`: Create an empty borrow array -//! - `borrow`: Extract element at index from array -//! - `return`: Put element back into array -//! - `discard_all_borrowed`: Finalize/cleanup (no-op for simulation) +//! Operations (signatures per the HUGR std extension): +//! - `new_all_borrowed`: `[] -> [array]` -- n slots, all holes +//! - `borrow`: `[array, usize] -> [array, elem]` -- take slot, leave hole +//! - `return`: `[array, usize, elem] -> [array]` -- fill hole +//! - `is_borrowed`: `[array, usize] -> [array, bool]` +//! - `discard_all_borrowed`: `[array] -> []` -- consume the array +//! +//! Handlers defer (return false) on missing inputs so consumers never see +//! fabricated arrays or elements; a permanently missing input surfaces via +//! completion-time stall detection. use log::debug; -use tket::hugr::ops::OpTrait; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { /// Handle `collections.borrow_arr` operations. #[allow(clippy::too_many_lines)] - pub(crate) fn handle_borrow_arr_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_borrow_arr_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing collections.borrow_arr operation: {op_name} at {node:?}"); match op_name { "new_all_borrowed" => { - // new_all_borrowed: create an empty borrow array. - // The actual elements get populated via subsequent `return` operations. - // Input port 0 = the original array to borrow from. - // Output port 0 = borrow array (initially clone the input array). + // [] -> [array]: n slots, all borrowed-out (holes). The size + // comes from the op's first type arg: a concrete BoundedNat, + // or (inside a generic function) a type variable resolved + // through the active call chain. Defer if unresolvable -- a + // fabricated 0-slot array makes every later return/borrow + // out of bounds. let op = hugr.get_optype(node); - let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); + let size = op.as_extension_op().and_then(|ext| { + ext.args().iter().find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => Some(*n), + tket::hugr::types::TypeArg::Variable(var) => { + self.resolve_call_type_arg(hugr, node, var.index()) + } + _ => None, + }) + }); + let Some(size) = size else { + debug!("new_all_borrowed at {node:?}: size unresolved, deferring"); + return HandlerOutcome::Defer; + }; + #[allow(clippy::cast_possible_truncation)] // Array sizes fit in usize + let elements = vec![ClassicalValue::Borrowed; size as usize]; + debug!("new_all_borrowed: created {size}-slot all-borrowed array"); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + HandlerOutcome::Processed + } + "borrow" => { + // [array, usize] -> [array, elem]: take the element at the + // index, leaving a hole. + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("borrow at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize + let Some(index) = self + .get_input_value(hugr, node, 1) + .and_then(|v| v.as_uint()) + .map(|v| v as usize) + else { + debug!("borrow at {node:?}: index not ready, deferring"); + return HandlerOutcome::Defer; + }; - if num_inputs > 0 { - // If there's an input array, use it as the borrow array - if let Some(array_val) = self.get_input_value(hugr, node, 0) { - self.wire_state - .classical_values - .insert((node, 0), array_val); - debug!("new_all_borrowed: cloned input array as borrow array"); - } else { - // No input value found; create empty array - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(vec![])); - debug!("new_all_borrowed: created empty borrow array (no input)"); - } - } else { - // No inputs; create empty array - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(vec![])); - debug!("new_all_borrowed: created empty borrow array"); + let Some(slot) = elements.get_mut(index) else { + debug!( + "borrow at {node:?}: index {index} out of bounds (len={}), deferring", + elements.len() + ); + return HandlerOutcome::Defer; + }; + let element = std::mem::replace(slot, ClassicalValue::Borrowed); + if matches!(element, ClassicalValue::Borrowed) { + // Guppy guards accesses with is_borrowed + panic, so a + // hole here means an upstream value was wrong; defer so + // stall detection names this node instead of handing a + // fabricated element downstream. + debug!("borrow at {node:?}: slot {index} already borrowed, deferring"); + return HandlerOutcome::Defer; } - // Propagate qubit wires if present - for port in 0..num_inputs { - if let Some(qubit) = self.get_input_qubit(hugr, node, port) { - self.wire_state.wire_to_qubit.insert((node, port), qubit); - } + if let ClassicalValue::QubitRef(qubit_id) = &element { + self.wire_state.wire_to_qubit.insert((node, 1), *qubit_id); } + self.wire_state.classical_values.insert((node, 1), element); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + debug!("borrow[{index}]: extracted element"); + HandlerOutcome::Processed + } + "return" => { + // [array, usize, elem] -> [array]: fill the hole at the index. + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("return at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize + let Some(index) = self + .get_input_value(hugr, node, 1) + .and_then(|v| v.as_uint()) + .map(|v| v as usize) + else { + debug!("return at {node:?}: index not ready, deferring"); + return HandlerOutcome::Defer; + }; + // A qubit element arrives on the qubit wire; other elements + // as classical values. + let element = if let Some(qubit_id) = self.get_input_qubit(hugr, node, 2) { + ClassicalValue::QubitRef(qubit_id) + } else if let Some(value) = self.get_input_value(hugr, node, 2) { + value + } else { + debug!("return at {node:?}: element not ready, deferring"); + return HandlerOutcome::Defer; + }; - true + let Some(slot) = elements.get_mut(index) else { + debug!( + "return at {node:?}: index {index} out of bounds (len={}), deferring", + elements.len() + ); + return HandlerOutcome::Defer; + }; + *slot = element; + debug!("return[{index}]: element returned to borrow array"); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + HandlerOutcome::Processed } - "borrow" => { - // borrow: extract element at index from array. - // Input port 0 = borrow_array, port 1 = int index - // Output port 0 = borrow_array (unchanged for simulation), port 1 = element - let array = self.get_input_value(hugr, node, 0); + "is_borrowed" => { + // [array, usize] -> [array, bool] + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("is_borrowed at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize - let index = self + let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; + .map(|v| v as usize) + else { + debug!("is_borrowed at {node:?}: index not ready, deferring"); + return HandlerOutcome::Defer; + }; - if let Some(ClassicalValue::Array(elements)) = array { - // Output port 0 = the array (unchanged for simulation purposes) - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements.clone())); + let borrowed = elements + .get(index) + .is_none_or(|slot| matches!(slot, ClassicalValue::Borrowed)); + debug!("is_borrowed[{index}]: {borrowed}"); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Bool(borrowed)); + HandlerOutcome::Processed + } + "get" => { + // [array, usize] -> [Sum([[], [T]]), array]: copy the element + // at the index (copyable element types only -- no hole is + // left). Out-of-bounds or borrowed slots yield the None + // variant, matching the std extension's option result. + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("get at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize + let Some(index) = self + .get_input_value(hugr, node, 1) + .and_then(|v| v.as_uint()) + .map(|v| v as usize) + else { + debug!("get at {node:?}: index not ready, deferring"); + return HandlerOutcome::Defer; + }; - if let Some(element) = elements.get(index).cloned() { - // Output port 1 = the borrowed element - // If element is a QubitRef, also propagate to wire_to_qubit - if let ClassicalValue::QubitRef(qubit_id) = &element { - self.wire_state.wire_to_qubit.insert((node, 1), *qubit_id); + let result = match elements.get(index) { + Some(slot) if !matches!(slot, ClassicalValue::Borrowed) => { + ClassicalValue::Sum { + tag: 1, + values: vec![slot.clone()], } - self.wire_state.classical_values.insert((node, 1), element); - - debug!("borrow[{index}]: extracted element"); + } + _ => ClassicalValue::Sum { + tag: 0, + values: vec![], + }, + }; + debug!("get[{index}]: {result:?}"); + self.wire_state.classical_values.insert((node, 0), result); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Array(elements)); + HandlerOutcome::Processed + } + "new_array" => { + // [T; n] -> [array]: construct from elements. A missing + // element defers (skipping would shorten the array and shift + // every index); qubit elements ride as QubitRef. + use tket::hugr::ops::OpTrait; + let op = hugr.get_optype(node); + let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); + let mut elements = Vec::with_capacity(num_inputs); + for port in 0..num_inputs { + if let Some(qubit_id) = self.get_input_qubit(hugr, node, port) { + elements.push(ClassicalValue::QubitRef(qubit_id)); + } else if let Some(value) = self.get_input_value(hugr, node, port) { + elements.push(value); } else { debug!( - "borrow[{index}]: index out of bounds (len={})", - elements.len() + "borrow_arr.new_array at {node:?}: element {port} not ready, deferring" ); + return HandlerOutcome::Defer; } - } else { - debug!("borrow: no array found on input port 0"); - // Try pass-through - self.propagate_all_inputs(hugr, node); } - true + debug!("borrow_arr.new_array: created {} elements", elements.len()); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + HandlerOutcome::Processed } - "return" => { - // return: put element back into array. - // Input port 0 = borrow_array, port 1 = index (usize), port 2 = element - // Output port 0 = updated array - let array = self.get_input_value(hugr, node, 0); - #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize - let index = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()) - .map(|v| v as usize); - let element = self.get_input_value(hugr, node, 2); - - // Also check if the element is a qubit - let element_qubit = self.get_input_qubit(hugr, node, 2); - - // If the element isn't available yet (e.g., waiting for measurement result), - // defer this operation. Return false so the main loop adds us to pending. - if element.is_none() && element_qubit.is_none() { - debug!("return: element not available yet, deferring"); - return false; - } - - if let Some(ClassicalValue::Array(mut elements)) = array { - if let Some(val) = element { - // The element might need to be wrapped as QubitRef - let val = if let Some(qubit_id) = element_qubit { - ClassicalValue::QubitRef(qubit_id) - } else { - val - }; - // Put the element at the specified index - if let Some(idx) = index { - // Extend array if needed - while elements.len() <= idx { - elements.push(ClassicalValue::Bool(false)); - } - elements[idx] = val; - debug!("return[{idx}]: element returned to borrow array"); - } else { - // No index -- append to array - elements.push(val); - debug!("return: element appended to borrow array"); - } - } else if let Some(qubit_id) = element_qubit { - // Element is a qubit (no classical value, just qubit wire) - let val = ClassicalValue::QubitRef(qubit_id); - if let Some(idx) = index { - while elements.len() <= idx { - elements.push(ClassicalValue::Bool(false)); - } - elements[idx] = val; - debug!("return[{idx}]: qubit returned to borrow array"); - } else { - elements.push(val); - debug!("return: qubit appended to borrow array"); - } + "pop_left" => { + // [array] -> [Sum([[], [T, array]])]: take the + // first element; the empty variant (tag 0) when nothing is + // left, else (element, rest) in the value variant (tag 1). + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("pop_left at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + let result = if elements.is_empty() { + debug!("pop_left at {node:?}: empty array -> None variant"); + ClassicalValue::Sum { + tag: 0, + values: vec![], } - - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); } else { - // Array not available - might need to defer - if array.is_none() { - debug!("return: array not available yet, deferring"); - return false; + if matches!(elements[0], ClassicalValue::Borrowed) { + // The front slot is borrowed out: popping the hole + // would silently drop the element when it returns. + // Defer, matching `borrow` on a borrowed slot. + debug!("pop_left at {node:?}: front slot borrowed, deferring"); + return HandlerOutcome::Defer; } - debug!("return: no array found on input port 0, passing through"); - self.propagate_all_inputs(hugr, node); + let element = elements.remove(0); + debug!( + "pop_left at {node:?}: popped element, {} remain", + elements.len() + ); + ClassicalValue::Sum { + tag: 1, + values: vec![element, ClassicalValue::Array(elements)], + } + }; + self.wire_state.classical_values.insert((node, 0), result); + HandlerOutcome::Processed + } + "discard_empty" => { + // [array<0,T>] -> []: consume an empty array. Defer until + // the value exists so the op is not marked done while its + // producer is pending. + if self.get_input_value(hugr, node, 0).is_none() { + debug!("discard_empty at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; } - true + debug!("discard_empty: array consumed"); + HandlerOutcome::Processed + } + "clone" => { + // [array] -> [array, array] (copyable elements): duplicate. + let Some(value @ ClassicalValue::Array(_)) = self.get_input_value(hugr, node, 0) + else { + debug!("borrow_arr.clone at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + self.wire_state + .classical_values + .insert((node, 0), value.clone()); + self.wire_state.classical_values.insert((node, 1), value); + debug!("borrow_arr.clone at {node:?}: duplicated"); + HandlerOutcome::Processed + } + "to_array" | "from_array" => { + // borrow_array <-> array conversions: identity on the + // engine's shared Array representation. + let Some(value @ ClassicalValue::Array(_)) = self.get_input_value(hugr, node, 0) + else { + debug!("borrow_arr.{op_name} at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + self.wire_state.classical_values.insert((node, 0), value); + debug!("borrow_arr.{op_name} at {node:?}: converted"); + HandlerOutcome::Processed } "discard_all_borrowed" => { - // discard_all_borrowed: finalize/cleanup. - // Input port 0 = borrow_array, Output port 0 = the original array - // For simulation, we just pass through the array value. - let array = self.get_input_value(hugr, node, 0); - // eprintln!("[BORROW_ARR] discard_all_borrowed at {node:?}: array={array:?}"); - if let Some(arr) = array { - // eprintln!("[BORROW_ARR] discard_all_borrowed: propagating {:?}", arr); - debug!("discard_all_borrowed: propagating array value {arr:?}"); - self.wire_state.classical_values.insert((node, 0), arr); - } else { - // Array not available yet - defer until it's ready - // eprintln!("[BORROW_ARR] discard_all_borrowed: deferring (array not available)"); - debug!("discard_all_borrowed: array not available yet, deferring"); - return false; + // [array] -> []: consumes the (all-borrowed) array; nothing + // to produce. Defer until the array value exists so the op + // is not marked done while its producer is still pending. + if self.get_input_value(hugr, node, 0).is_none() { + debug!("discard_all_borrowed at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; } - true + debug!("discard_all_borrowed: array consumed"); + HandlerOutcome::Processed } + "scan" => self.handle_scan_op(hugr, node), _ => { - // For unknown borrow_arr operations, try pass-through + // Unknown op: defer so it surfaces in the stall report + // instead of silently passing values through. debug!( - "Unknown collections.borrow_arr operation: {op_name} - attempting pass-through" + "Unknown collections.borrow_arr operation: {op_name} at {node:?}, deferring" ); - self.propagate_all_inputs(hugr, node); - true + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 99217bacf..332af3b98 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -27,15 +27,43 @@ //! Also handles `tket.bool` extension operations. use log::debug; -use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; +use tket::hugr::ops::OpType; +use tket::hugr::{Hugr, HugrView, IncomingPort, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::{ClassicalOutcome, HandlerOutcome}; use crate::engine::types::{ClassicalOp, ClassicalOpType, ClassicalValue}; +/// Mask for the low `2^log_width` bits (`log_width` >= 6 means full `i64`). +pub(crate) fn width_mask(log_width: u8) -> u64 { + if log_width >= 6 { + u64::MAX + } else { + (1u64 << (1u32 << log_width)) - 1 + } +} + +/// Canonicalize a value to the op's width: mask to `2^log_width` bits and +/// sign-extend, so the stored i64 is the two's-complement value the width +/// implies (e.g. `int<5>` `0xFFFF_FFFF` stores as -1). Matches `ConstInt` +/// parsing, which stores `value_s()` (sign-extended) for every width. +#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] +pub(crate) fn canonicalize_width(value: i64, log_width: u8) -> i64 { + if log_width >= 6 { + return value; + } + let bits = 1u32 << log_width; + let masked = (value as u64) & width_mask(log_width); + ((masked << (64 - bits)) as i64) >> (64 - bits) +} + impl HugrEngine { - /// Execute a classical operation and return the output values. + /// Execute a classical operation. /// - /// Returns a vector of (`port_index`, value) pairs for output ports. + /// Returns [`ClassicalOutcome::Outputs`] with (`port_index`, value) + /// pairs on success, `Defer` when an input is missing or + /// unconvertible, and `Fault` for unrecoverable spec-defined errors + /// (e.g. unchecked division by zero). #[allow( clippy::too_many_lines, clippy::float_cmp, // Exact float comparison is intentional for feq/fne operations @@ -44,464 +72,627 @@ impl HugrEngine { clippy::cast_sign_loss // shift amounts are clamped to 0-63 before cast to u32 )] pub(crate) fn handle_classical_op( - &self, + &mut self, hugr: &Hugr, node: Node, op: &ClassicalOp, - ) -> Vec<(usize, ClassicalValue)> { - // Collect input values + ) -> ClassicalOutcome { + // Collect input values. get_input_value (not a raw wire read) so + // values are found across flattened-DFG boundaries -- the executor + // must see exactly what the tracing layer sees, or a classical op + // fed by a nested DFG defers forever. let mut inputs = Vec::with_capacity(op.num_inputs); for port_idx in 0..op.num_inputs { let in_port = IncomingPort::from(port_idx); - if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { - let wire_key = (src_node, src_port.index()); - if let Some(value) = self.wire_state.classical_values.get(&wire_key) { - inputs.push(value.clone()); - } else { - debug!( - "Classical op {node:?}: missing input value for port {port_idx} from {wire_key:?}" - ); - return vec![]; - } - } else { + if hugr.single_linked_output(node, in_port).is_none() { debug!("Classical op {node:?}: no source for input port {port_idx}"); - return vec![]; + return ClassicalOutcome::Defer; + } + if let Some(value) = self.get_input_value(hugr, node, port_idx) { + inputs.push(value); + } else if matches!(op.op_type, ClassicalOpType::TagSum) + && let Some(qubit_id) = self.get_input_qubit(hugr, node, port_idx) + { + // A Tag may carry linear payload elements (e.g. an + // iterator's Option over (qubit, state)): represent the + // qubit as a QubitRef so the Sum value materializes. + // Scoped to TagSum only -- a qubit input to an + // arithmetic op is a semantic error, not a value. + inputs.push(ClassicalValue::QubitRef(qubit_id)); + } else { + debug!("Classical op {node:?}: missing input value for port {port_idx}"); + return ClassicalOutcome::Defer; } } - // Execute the operation - let result = match op.op_type { - // Logic operations - ClassicalOpType::And => { - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(a && b) - } - ClassicalOpType::Or => { - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(a || b) - } - ClassicalOpType::Not => { - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(!a) - } - ClassicalOpType::Xor => { - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(a ^ b) - } - ClassicalOpType::Eq => { - // Eq can work on bools - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(a == b) - } - - // Integer arithmetic - ClassicalOpType::Iadd => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_add(b)) - } - ClassicalOpType::Isub => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_sub(b)) + // Multi-output / special arms first (they return whole port lists). + match op.op_type { + // Constants (shouldn't be processed as operations, but handle anyway) + ClassicalOpType::ConstInt + | ClassicalOpType::ConstFloat + | ClassicalOpType::ConstBool => { + return op + .const_value + .as_ref() + .map_or(ClassicalOutcome::Defer, |value| { + ClassicalOutcome::Outputs(vec![(0, value.clone())]) + }); } - ClassicalOpType::Imul => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_mul(b)) + ClassicalOpType::MakeTuple => { + // MakeTuple combines all inputs into a single tuple + return ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Tuple(inputs))]); } - ClassicalOpType::Idiv => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(1); - if b == 0 { - ClassicalValue::Int(0) // Avoid division by zero - } else { - ClassicalValue::Int(a.wrapping_div(b)) + ClassicalOpType::UnpackTuple => { + // UnpackTuple takes a single tuple input and produces multiple + // outputs. A tuple is a 1-variant sum, so accept a Sum payload + // the same way (e.g. a tuple that crossed a CFG/Call boundary + // as a tagged value). + let tuple_value = inputs.into_iter().next(); + match tuple_value { + Some( + ClassicalValue::Tuple(elements) + | ClassicalValue::Sum { + values: elements, .. + }, + ) => { + // Return each element on its respective output port + return ClassicalOutcome::Outputs( + elements.into_iter().enumerate().collect(), + ); + } + Some(value) => { + // If it's a single non-tuple value, just pass it through on port 0 + return ClassicalOutcome::Outputs(vec![(0, value)]); + } + None => return ClassicalOutcome::Defer, } } - ClassicalOpType::Imod => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(1); - if b == 0 { - ClassicalValue::Int(0) + ClassicalOpType::LoadFunc => { + // Resolve the static FuncDefn target into a function value, + // carrying the instantiation's type args (generic functions + // executed through scan resolve type-level naturals from + // them, symmetric with Call frames). + let Some(func_defn) = hugr.static_source(node) else { + return ClassicalOutcome::Fault(format!( + "LoadFunction at {node:?} has no static source" + )); + }; + let type_args = match hugr.get_optype(node) { + OpType::LoadFunction(lf) => lf.type_args.clone(), + _ => Vec::new(), + }; + return ClassicalOutcome::Outputs(vec![( + 0, + ClassicalValue::FuncRef(func_defn, type_args), + )]); + } + ClassicalOpType::TagSum => { + // Tag wraps its inputs into the given variant of a sum. + let OpType::Tag(tag_op) = hugr.get_optype(node) else { + // Classified as TagSum but not a Tag op: an engine + // invariant violation that no retry can repair. + return ClassicalOutcome::Fault(format!( + "node {node:?} classified as TagSum is not a Tag op" + )); + }; + return ClassicalOutcome::Outputs(vec![( + 0, + ClassicalValue::Sum { + tag: tag_op.tag, + values: inputs, + }, + )]); + } + ClassicalOpType::Idivmod => { + // Combined Euclidean division+remainder, TWO outputs (q, r); + // m=0 panics per the spec, like Idiv/Imod. + let int_at = |i: usize| inputs.get(i).and_then(ClassicalValue::as_int); + let uint_at = |i: usize| match inputs.get(i) { + Some(ClassicalValue::UInt(u)) => Some(*u), + #[allow(clippy::cast_sign_loss)] + other => other.and_then(ClassicalValue::as_int).map(|v| v as u64), + }; + let signed = op.int_info.is_none_or(|(_, is_signed)| is_signed); + let qr = if signed { + let (Some(n), Some(m)) = (int_at(0), uint_at(1)) else { + debug!("idivmod at {node:?}: inputs not ready, deferring"); + return ClassicalOutcome::Defer; + }; + let (n, m) = (i128::from(n), i128::from(m)); + if m == 0 { + None + } else { + #[allow(clippy::cast_possible_truncation)] + Some((n.div_euclid(m) as i64, n.rem_euclid(m) as i64)) + } } else { - ClassicalValue::Int(a.wrapping_rem(b)) - } - } - ClassicalOpType::Ineg => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_neg()) - } - ClassicalOpType::Iabs => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_abs()) + let (Some(a), Some(b)) = (uint_at(0), uint_at(1)) else { + debug!("idivmod at {node:?}: inputs not ready, deferring"); + return ClassicalOutcome::Defer; + }; + #[allow(clippy::cast_possible_wrap)] + (b != 0).then(|| ((a / b) as i64, (a % b) as i64)) + }; + let Some((q, r)) = qr else { + return ClassicalOutcome::Fault(format!( + "division by zero at {node:?} (the HUGR spec defines m=0 as a panic)" + )); + }; + return ClassicalOutcome::Outputs(vec![ + (0, ClassicalValue::Int(q)), + (1, ClassicalValue::Int(r)), + ]); + } + ClassicalOpType::IdivmodChecked => { + // sum_with_error(tuple(q, r)): error = tag 0, value = tag 1. + let int_at = |i: usize| inputs.get(i).and_then(ClassicalValue::as_int); + let uint_at = |i: usize| match inputs.get(i) { + Some(ClassicalValue::UInt(u)) => Some(*u), + #[allow(clippy::cast_sign_loss)] + other => other.and_then(ClassicalValue::as_int).map(|v| v as u64), + }; + let signed = op.int_info.is_none_or(|(_, is_signed)| is_signed); + let qr = if signed { + let (Some(n), Some(m)) = (int_at(0), uint_at(1)) else { + debug!("idivmod_checked at {node:?}: inputs not ready, deferring"); + return ClassicalOutcome::Defer; + }; + let (n, m) = (i128::from(n), i128::from(m)); + #[allow(clippy::cast_possible_truncation)] + (m != 0).then(|| (n.div_euclid(m) as i64, n.rem_euclid(m) as i64)) + } else { + let (Some(a), Some(b)) = (uint_at(0), uint_at(1)) else { + debug!("idivmod_checked at {node:?}: inputs not ready, deferring"); + return ClassicalOutcome::Defer; + }; + #[allow(clippy::cast_possible_wrap)] + (b != 0).then(|| ((a / b) as i64, (a % b) as i64)) + }; + let value = match qr { + Some((q, r)) => ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Tuple(vec![ + ClassicalValue::Int(q), + ClassicalValue::Int(r), + ])], + }, + None => ClassicalValue::Sum { + // The error variant of sum_with_error carries an + // error payload; an opaque token keeps the case + // Input port arity correct for propagation. + tag: 0, + values: vec![ClassicalValue::Tuple(vec![])], + }, + }; + return ClassicalOutcome::Outputs(vec![(0, value)]); } + _ => {} + } - // Integer comparisons - ClassicalOpType::Ieq => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a == b) - } - ClassicalOpType::Ine => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a != b) - } - ClassicalOpType::Ilt => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a < b) - } - ClassicalOpType::Ile => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a <= b) - } - ClassicalOpType::Igt => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a > b) - } - ClassicalOpType::Ige => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a >= b) - } + // Typed extraction for the scalar arms below: a PRESENT but + // unconvertible input is the same hazard as a missing one -- + // defaulting (the old unwrap_or(0/false/0.0)) silently computes on + // fabricated operands, so extraction failure defers the whole op. + // Extraction is width-aware: the op's declared width (int_info) + // masks unsigned reads and canonicalizes signed ones, so narrow + // ints (int<5> = 32-bit etc.) compute exactly. + let log_width = op.int_info.map_or(6, |(lw, _)| lw); + let int = |i: usize| { + inputs + .get(i) + .and_then(ClassicalValue::as_int) + .map(|v| canonicalize_width(v, log_width)) + }; + // Unsigned ops reinterpret the stored i64 bit pattern: wrapping + // arithmetic stores results through Int, so as_uint (which rejects + // negatives) would spuriously defer on e.g. u64::MAX. + #[allow(clippy::cast_sign_loss)] + let uint = |i: usize| { + match inputs.get(i) { + Some(ClassicalValue::UInt(u)) => Some(*u), + other => other.and_then(ClassicalValue::as_int).map(|v| v as u64), + } + .map(|v| v & width_mask(log_width)) + }; + let boolean = |i: usize| inputs.get(i).and_then(ClassicalValue::as_bool); + let float = |i: usize| inputs.get(i).and_then(ClassicalValue::as_float); + // Classified arithmetic.int ops carry their signedness; ops without + // int_info (logic/float/etc.) never consult it. + let signed = op.int_info.is_none_or(|(_, is_signed)| is_signed); - // Integer bitwise operations - ClassicalOpType::Iand => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a & b) - } - ClassicalOpType::Ior => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a | b) - } - ClassicalOpType::Ixor => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a ^ b) - } - ClassicalOpType::Inot => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(!a) - } - ClassicalOpType::Ishl => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - // Clamp shift amount to valid range (0-63 for i64) - let shift = b.clamp(0, 63) as u32; - ClassicalValue::Int(a.wrapping_shl(shift)) - } - ClassicalOpType::Ishr => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - // Clamp shift amount to valid range (0-63 for i64) - let shift = b.clamp(0, 63) as u32; - ClassicalValue::Int(a.wrapping_shr(shift)) - } + // Execute the operation + let mut div_by_zero = false; + #[allow(clippy::cast_possible_wrap)] + let result: Option = (|| { + Some(match op.op_type { + // Logic operations + ClassicalOpType::And => ClassicalValue::Bool(boolean(0)? && boolean(1)?), + ClassicalOpType::Or => ClassicalValue::Bool(boolean(0)? || boolean(1)?), + ClassicalOpType::Not => ClassicalValue::Bool(!boolean(0)?), + ClassicalOpType::Xor => ClassicalValue::Bool(boolean(0)? ^ boolean(1)?), + ClassicalOpType::Eq => ClassicalValue::Bool(boolean(0)? == boolean(1)?), - // Float arithmetic - ClassicalOpType::Fadd => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a + b) - } - ClassicalOpType::Fsub => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a - b) - } - ClassicalOpType::Fmul => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a * b) - } - ClassicalOpType::Fdiv => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(1.0); - ClassicalValue::Float(a / b) - } - ClassicalOpType::Fneg => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(-a) - } - ClassicalOpType::Fabs => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a.abs()) - } - ClassicalOpType::Ffloor => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a.floor()) - } - ClassicalOpType::Fceil => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a.ceil()) - } + // Integer arithmetic (add/sub/mul are sign-agnostic modulo 2^64) + ClassicalOpType::Iadd => ClassicalValue::Int(int(0)?.wrapping_add(int(1)?)), + ClassicalOpType::Isub => ClassicalValue::Int(int(0)?.wrapping_sub(int(1)?)), + ClassicalOpType::Imul => ClassicalValue::Int(int(0)?.wrapping_mul(int(1)?)), + ClassicalOpType::Idiv => { + // Division by zero panics per the spec ("m=0 will call + // panic"); raised as a fatal execution fault. Signed + // division is EUCLIDEAN per the HUGR spec (idivmod_s: + // q*m+r=n with 0<=r { + // Euclidean remainder for signed (0 <= r < m), see Idiv; + // modulo by zero panics per the spec. + if signed { + let (n, m) = (i128::from(int(0)?), i128::from(uint(1)?)); + if m == 0 { + div_by_zero = true; + return None; + } + ClassicalValue::Int(n.rem_euclid(m) as i64) + } else { + let (a, b) = (uint(0)?, uint(1)?); + let Some(r) = a.checked_rem(b) else { + div_by_zero = true; + return None; + }; + ClassicalValue::Int(r as i64) + } + } + // Checked variants return sum_with_error(int): tag 1 wraps + // the value, tag 0 is the error variant. The error payload + // (a prelude error value) is not modeled -- correct programs + // never take that branch, and one that does stalls loudly on + // the missing payload instead of computing on a fabricated + // value. + ClassicalOpType::IdivChecked => { + // Signed checked division is Euclidean, like Idiv. + let ok = if signed { + let (n, m) = (i128::from(int(0)?), i128::from(uint(1)?)); + (m != 0).then(|| n.div_euclid(m) as i64) + } else { + let (a, b) = (uint(0)?, uint(1)?); + a.checked_div(b).map(|q| q as i64) + }; + match ok { + Some(q) => ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(q)], + }, + None => ClassicalValue::Sum { + // sum_with_error error variants carry a payload + tag: 0, + values: vec![ClassicalValue::Tuple(vec![])], + }, + } + } + ClassicalOpType::ImodChecked => { + // Euclidean remainder (0 <= r < m), like Imod. + let ok = if signed { + let (n, m) = (i128::from(int(0)?), i128::from(uint(1)?)); + (m != 0).then(|| n.rem_euclid(m) as i64) + } else { + let (a, b) = (uint(0)?, uint(1)?); + a.checked_rem(b).map(|r| r as i64) + }; + match ok { + Some(r) => ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(r)], + }, + None => ClassicalValue::Sum { + // sum_with_error error variants carry a payload + tag: 0, + values: vec![ClassicalValue::Tuple(vec![])], + }, + } + } + ClassicalOpType::Ineg => ClassicalValue::Int(int(0)?.wrapping_neg()), + ClassicalOpType::Iabs => ClassicalValue::Int(int(0)?.wrapping_abs()), - // Float comparisons - ClassicalOpType::Feq => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a == b) - } - ClassicalOpType::Fne => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a != b) - } - ClassicalOpType::Flt => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a < b) - } - ClassicalOpType::Fle => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a <= b) - } - ClassicalOpType::Fgt => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a > b) - } - ClassicalOpType::Fge => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a >= b) - } + // Integer comparisons (ordering is signedness-sensitive) + ClassicalOpType::Ieq => ClassicalValue::Bool(int(0)? == int(1)?), + ClassicalOpType::Ine => ClassicalValue::Bool(int(0)? != int(1)?), + ClassicalOpType::Ilt => ClassicalValue::Bool(if signed { + int(0)? < int(1)? + } else { + uint(0)? < uint(1)? + }), + ClassicalOpType::Ile => ClassicalValue::Bool(if signed { + int(0)? <= int(1)? + } else { + uint(0)? <= uint(1)? + }), + ClassicalOpType::Igt => ClassicalValue::Bool(if signed { + int(0)? > int(1)? + } else { + uint(0)? > uint(1)? + }), + ClassicalOpType::Ige => ClassicalValue::Bool(if signed { + int(0)? >= int(1)? + } else { + uint(0)? >= uint(1)? + }), - // Conversions - ClassicalOpType::ConvertIntToFloat => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Float(a as f64) - } - ClassicalOpType::ConvertFloatToInt => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - // Truncate toward zero, matching standard float-to-int semantics - ClassicalValue::Int(a.trunc() as i64) - } + // Integer bitwise operations (sign-agnostic) + ClassicalOpType::Iand => ClassicalValue::Int(int(0)? & int(1)?), + ClassicalOpType::Ior => ClassicalValue::Int(int(0)? | int(1)?), + ClassicalOpType::Ixor => ClassicalValue::Int(int(0)? ^ int(1)?), + ClassicalOpType::Inot => ClassicalValue::Int(!int(0)?), + #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)] + ClassicalOpType::Ishl => { + // "leftmost bits dropped, rightmost bits set to zero": + // shifting by k >= N drops every bit. + let k = uint(1)?; + let bits = u64::from(1u32 << log_width); + if k >= bits { + return Some(ClassicalValue::Int(0)); + } + #[allow(clippy::cast_possible_truncation)] + ClassicalValue::Int((uint(0)? << (k as u32)) as i64) + } + #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)] + ClassicalOpType::Ishr => { + // LOGICAL shift per the spec ("rightmost bits dropped, + // leftmost bits set to zero") -- ishr has no signed + // variant, and shifting by k >= N drops every bit. + let k = uint(1)?; + let bits = u64::from(1u32 << log_width); + if k >= bits { + return Some(ClassicalValue::Int(0)); + } + #[allow(clippy::cast_possible_truncation)] + ClassicalValue::Int((uint(0)? >> (k as u32)) as i64) + } + ClassicalOpType::Ipow => { + // "raise first input to the power of second input, the + // exponent is treated as an unsigned integer"; wrapping + // square-and-multiply so huge exponents stay exact under + // two's-complement wrap. + let (mut base, mut exp) = (int(0)?, uint(1)?); + let mut result: i64 = 1; + while exp > 0 { + if exp & 1 == 1 { + result = result.wrapping_mul(base); + } + base = base.wrapping_mul(base); + exp >>= 1; + } + ClassicalValue::Int(result) + } + ClassicalOpType::ItoBool => { + // itobool: int<1> -> bool (1 is true, 0 is false) + ClassicalValue::Bool(int(0)? != 0) + } + ClassicalOpType::IfromBool => { + // ifrombool: bool -> int<1> + ClassicalValue::Int(i64::from(boolean(0)?)) + } - // Constants (shouldn't be processed as operations, but handle anyway) - ClassicalOpType::ConstInt - | ClassicalOpType::ConstFloat - | ClassicalOpType::ConstBool => { - if let Some(value) = &op.const_value { - value.clone() + // Float arithmetic + ClassicalOpType::Fadd => ClassicalValue::Float(float(0)? + float(1)?), + ClassicalOpType::Fsub => ClassicalValue::Float(float(0)? - float(1)?), + ClassicalOpType::Fmul => ClassicalValue::Float(float(0)? * float(1)?), + ClassicalOpType::Fdiv => ClassicalValue::Float(float(0)? / float(1)?), + ClassicalOpType::Fneg => ClassicalValue::Float(-float(0)?), + ClassicalOpType::Fabs => ClassicalValue::Float(float(0)?.abs()), + ClassicalOpType::Ffloor => ClassicalValue::Float(float(0)?.floor()), + ClassicalOpType::Fceil => ClassicalValue::Float(float(0)?.ceil()), + + // Float comparisons (exact comparison is intentional) + ClassicalOpType::Feq => ClassicalValue::Bool(float(0)? == float(1)?), + ClassicalOpType::Fne => ClassicalValue::Bool(float(0)? != float(1)?), + ClassicalOpType::Flt => ClassicalValue::Bool(float(0)? < float(1)?), + ClassicalOpType::Fle => ClassicalValue::Bool(float(0)? <= float(1)?), + ClassicalOpType::Fgt => ClassicalValue::Bool(float(0)? > float(1)?), + ClassicalOpType::Fge => ClassicalValue::Bool(float(0)? >= float(1)?), + + #[allow(clippy::cast_precision_loss)] + ClassicalOpType::ConvertIntToFloat => ClassicalValue::Float(if signed { + int(0)? as f64 } else { - return vec![]; + uint(0)? as f64 + }), + #[allow(clippy::cast_possible_truncation)] + ClassicalOpType::ConvertFloatToInt => { + // Truncate toward zero, matching standard float-to-int semantics + ClassicalValue::Int(float(0)?.trunc() as i64) } - } - - // Tuple operations - these have special return handling - ClassicalOpType::MakeTuple => { - // MakeTuple combines all inputs into a single tuple - // inputs already collected above - return vec![(0, ClassicalValue::Tuple(inputs))]; - } - ClassicalOpType::UnpackTuple => { - // UnpackTuple takes a single tuple input and produces multiple outputs - let tuple_value = inputs.into_iter().next(); - if let Some(ClassicalValue::Tuple(elements)) = tuple_value { - // Return each element on its respective output port - return elements.into_iter().enumerate().collect(); - } else if let Some(value) = tuple_value { - // If it's a single non-tuple value, just pass it through on port 0 - return vec![(0, value)]; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + ClassicalOpType::ConvertFloatToIntChecked => { + // trunc_s/trunc_u: sum_with_error(int) -- error (tag + // 0) for NaN/infinite/out-of-range AT THE OP'S WIDTH, + // value (tag 1) otherwise. The spec folds through + // ConstInt::new_s/new_u(log_width, ..), which rejects + // values that do not fit the declared width. + let f = float(0)?; + let t = f.trunc(); + // Strict upper bounds: the width's MAX as f64 can round + // UP to 2^bits (e.g. i64::MAX -> 2^63), so `<= MAX` + // would accept one out-of-range value and saturate + // instead of taking the error branch. The signed MIN + // (-2^(bits-1)) is exactly representable, so >= holds. + let bits_width = 1u32 << log_width.min(6); + let (lo, hi) = if signed { + let half = 2f64.powi(bits_width as i32 - 1); + (-half, half) + } else { + (0.0, 2f64.powi(bits_width as i32)) + }; + let in_range = t.is_finite() && (lo..hi).contains(&t); + if in_range { + let bits = if signed { t as i64 } else { (t as u64) as i64 }; + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(canonicalize_width(bits, log_width))], + } + } else { + ClassicalValue::Sum { + // sum_with_error error variants carry a payload + tag: 0, + values: vec![ClassicalValue::Tuple(vec![])], + } + } } - return vec![]; - } - }; - // Return output on port 0 - vec![(0, result)] + // Handled by the early match above + ClassicalOpType::ConstInt + | ClassicalOpType::ConstFloat + | ClassicalOpType::ConstBool + | ClassicalOpType::MakeTuple + | ClassicalOpType::UnpackTuple + | ClassicalOpType::TagSum + | ClassicalOpType::Idivmod + | ClassicalOpType::IdivmodChecked + | ClassicalOpType::LoadFunc => return None, + }) + })(); + + if let Some(value) = result { + // Results of width-carrying ops store CANONICAL: masked to the + // op's width and sign-extended (so wrapping at int<5> etc. is + // exact, and every consumer sees the value the width implies). + let value = match value { + ClassicalValue::Int(v) if op.int_info.is_some() => { + ClassicalValue::Int(canonicalize_width(v, log_width)) + } + other => other, + }; + ClassicalOutcome::Outputs(vec![(0, value)]) + } else if div_by_zero { + // The spec says unchecked division/modulo by zero panics. + ClassicalOutcome::Fault(format!( + "division by zero at {node:?} (the HUGR spec defines m=0 as a panic)" + )) + } else { + debug!("Classical op {node:?}: input type mismatch, deferring"); + ClassicalOutcome::Defer + } } /// Handle `tket.bool` operations. #[allow(clippy::too_many_lines)] // Boolean operation dispatch is inherently large - pub(crate) fn handle_bool_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_bool_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.bool operation: {op_name} at {node:?}"); match op_name { + // Binary/unary bool ops defer on missing or non-bool inputs + // instead of fabricating `false`: a fabricated operand commits a + // wrong branch value downstream (silent misexecution). "and" => { let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.bool.and at {node:?}: deferring - input not ready"); + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; + }; + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a && b)); debug!("tket.bool.and: {a} && {b} = {}", a && b); - true + HandlerOutcome::Processed } "or" => { let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.bool.or at {node:?}: deferring - input not ready"); + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; + }; + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a || b)); debug!("tket.bool.or: {a} || {b} = {}", a || b); - true + HandlerOutcome::Processed } "xor" => { let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.bool.xor at {node:?}: deferring - input not ready"); + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; + }; + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a ^ b)); debug!("tket.bool.xor: {a} ^ {b} = {}", a ^ b); - true + HandlerOutcome::Processed } "not" => { - let a = self + let Some(a) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_bool()) - .unwrap_or(false); + else { + debug!("tket.bool.not at {node:?}: deferring - input not ready"); + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; + }; + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(!a)); debug!("tket.bool.not: !{a} = {}", !a); - true + HandlerOutcome::Processed } "eq" => { let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.bool.eq at {node:?}: deferring - input not ready"); + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; + }; + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a == b)); debug!("tket.bool.eq: {a} == {b} = {}", a == b); - true + HandlerOutcome::Processed } "make_opaque" => { // make_opaque: Sum -> tket.bool @@ -513,19 +704,25 @@ impl HugrEngine { let Some(input_val) = input_value else { debug!("tket.bool.make_opaque at {node:?}: deferring - input not ready"); // Track this node so it can be retried when input becomes available - self.pending_bool_reads.insert(node); - return false; + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; }; - // Successfully resolved - remove from pending if it was there - self.pending_bool_reads.remove(&node); + // A present but non-bool value is the same hazard as a + // missing one: fabricating `false` commits a wrong value. + let Some(value) = input_val.as_bool() else { + debug!("tket.bool.make_opaque at {node:?}: deferring - input not a bool"); + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; + }; - let value = input_val.as_bool().unwrap_or(false); + // Successfully resolved - remove from pending if it was there + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(value)); debug!("tket.bool.make_opaque: {value}"); - true + HandlerOutcome::Processed } "read" => { // read: tket.bool -> Sum @@ -539,23 +736,27 @@ impl HugrEngine { let Some(input_val) = input_value else { debug!("tket.bool.read at {node:?}: deferring - input not ready"); // Track this node so it can be retried when measurement results arrive - self.pending_bool_reads.insert(node); - return false; + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; }; - // Successfully resolved - remove from pending if it was there - self.pending_bool_reads.remove(&node); + let Some(value) = input_val.as_bool() else { + debug!("tket.bool.read at {node:?}: deferring - input not a bool"); + self.deferred_nodes.insert(node); + return HandlerOutcome::Defer; + }; - let value = input_val.as_bool().unwrap_or(false); + // Successfully resolved - remove from pending if it was there + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(value)); debug!("tket.bool.read: {value}"); - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.bool operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/debug.rs b/crates/pecos-hugr/src/engine/handlers/debug.rs index 981a1bfae..f72891e89 100644 --- a/crates/pecos-hugr/src/engine/handlers/debug.rs +++ b/crates/pecos-hugr/src/engine/handlers/debug.rs @@ -21,21 +21,30 @@ use log::debug; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; impl HugrEngine { /// Handle tket.debug operations. - pub(crate) fn handle_debug_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_debug_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.debug operation: {op_name} at {node:?}"); if op_name == "StateResult" { // StateResult: array -> array // Pass-through for simulation; optionally log state info - self.propagate_qubit_array(hugr, node); + if !self.propagate_qubit_array(hugr, node) { + debug!("StateResult at {node:?}: input not resolved, deferring"); + return HandlerOutcome::Defer; + } debug!("StateResult at {node:?} (no-op for simulation)"); - true + HandlerOutcome::Processed } else { debug!("Unknown tket.debug operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/futures.rs b/crates/pecos-hugr/src/engine/handlers/futures.rs index 14224a4c1..767d37573 100644 --- a/crates/pecos-hugr/src/engine/handlers/futures.rs +++ b/crates/pecos-hugr/src/engine/handlers/futures.rs @@ -24,59 +24,85 @@ use log::debug; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::{ClassicalValue, FutureState}; impl HugrEngine { /// Handle tket.futures operations. - pub(crate) fn handle_futures_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_futures_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.futures operation: {op_name} at {node:?}"); match op_name { "Read" => { - // Read: Future -> T - // Resolve the Future to its value - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::Future(future_id) = value - && let Some(state) = self.extension_state.futures.get(&future_id) - { - match state { - FutureState::Resolved(outcome) => { - // Future is resolved, output the value - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Bool(*outcome != 0)); - debug!("Read future {future_id} -> {outcome}"); - } - FutureState::Pending { - measurement_index, .. - } => { - // Check if measurement result is available - if let Some((_, qubit)) = - self.measurement_state.mappings.get(*measurement_index) - { - if let Some(&result) = self.measurement_state.results.get(qubit) { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Bool(result != 0)); - debug!("Read future {future_id} from measurement -> {result}"); - } else { - // Result not yet available - use default - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Bool(false)); - debug!("Read future {future_id} pending, using default"); - } - } + // Read: Future -> T. Every unresolved shape defers: a + // missing input, a non-Future value, an unknown future id, + // or a pending measurement all mean the value does not exist + // yet -- returning handled would mark the node processed + // with no output and strand every consumer. + let Some(ClassicalValue::Future(future_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("futures.Read at {node:?}: future not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(state) = self.extension_state.futures.get(&future_id) else { + debug!("futures.Read at {node:?}: unknown future {future_id}, deferring"); + return HandlerOutcome::Defer; + }; + // Produce the future's DECLARED type: Future reads a + // Bool; Future (LazyMeasureLeaked, 0/1/2-leaked) reads + // an Int -- a Bool there loses the leak value. + let to_value = |outcome: u32, int_valued: bool| { + if int_valued { + ClassicalValue::Int(i64::from(outcome)) + } else { + ClassicalValue::Bool(outcome != 0) + } + }; + match state { + FutureState::Resolved { + outcome, + int_valued, + } => { + let value = to_value(*outcome, *int_valued); + self.wire_state.classical_values.insert((node, 0), value); + debug!("Read future {future_id} -> {outcome}"); + HandlerOutcome::Processed + } + FutureState::Pending { + measurement_index, + int_valued, + .. + } => { + if let Some((_, qubit)) = + self.measurement_state.mappings.get(*measurement_index) + && let Some(&result) = self.measurement_state.results.get(qubit) + { + let value = to_value(result, *int_valued); + self.wire_state.classical_values.insert((node, 0), value); + debug!("Read future {future_id} from measurement -> {result}"); + HandlerOutcome::Processed + } else { + // Result not yet available: defer -- retried + // when measurement results arrive. + debug!("Read future {future_id} pending, deferring"); + HandlerOutcome::Defer } } } - true } "Dup" => { // Dup: Future -> (Future, Future) // Create two new Futures pointing to the same result - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::Future(original_id) = value + let Some(ClassicalValue::Future(original_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("futures.Dup at {node:?}: future not ready, deferring"); + return HandlerOutcome::Defer; + }; { // Create two new Future IDs that share the same state let new_id1 = self.extension_state.next_future_id; @@ -100,22 +126,25 @@ impl HugrEngine { debug!("Dup future {original_id} -> {new_id1}, {new_id2}"); } - true + HandlerOutcome::Processed } "Free" => { // Free: Future -> () - // Discard the Future without reading - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::Future(future_id) = value - { - self.extension_state.futures.remove(&future_id); - debug!("Free future {future_id}"); - } - true + // Discard the Future without reading. Defer until the input + // resolves to an actual Future: succeeding without one marks + // the node processed while its producer never ran. + let Some(ClassicalValue::Future(future_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("Free at {node:?}: future not ready, deferring"); + return HandlerOutcome::Defer; + }; + self.extension_state.futures.remove(&future_id); + debug!("Free future {future_id}"); + HandlerOutcome::Processed } _ => { debug!("Unknown tket.futures operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/guppy.rs b/crates/pecos-hugr/src/engine/handlers/guppy.rs index 6459b3a8c..79119236d 100644 --- a/crates/pecos-hugr/src/engine/handlers/guppy.rs +++ b/crates/pecos-hugr/src/engine/handlers/guppy.rs @@ -21,11 +21,17 @@ use log::debug; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; impl HugrEngine { /// Handle `tket.guppy` operations. #[allow(clippy::unused_self)] // Consistent with other handler methods; may use self in future - pub(crate) fn handle_guppy_op(&mut self, _hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_guppy_op( + &mut self, + _hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.guppy operation: {op_name} at {node:?}"); if op_name == "drop" { @@ -33,15 +39,20 @@ impl HugrEngine { // Drop an affine type value (opposite of move semantics) // No-op for simulation - just consumes the value debug!("tket.guppy.drop at {node:?} (value consumed)"); - true + HandlerOutcome::Processed } else { debug!("Unknown tket.guppy operation: {op_name}"); - false + HandlerOutcome::Defer } } /// Handle `guppylang` extension operations. - pub(crate) fn handle_guppylang_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_guppylang_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing guppylang operation: {op_name} at {node:?}"); match op_name { @@ -50,19 +61,23 @@ impl HugrEngine { // Log a warning but allow execution to continue debug!("guppylang.unsupported at {node:?} - operation not supported"); // Pass through any inputs to outputs - self.propagate_all_inputs(hugr, node); - true + if !self.propagate_all_inputs(hugr, node) { + return HandlerOutcome::Defer; + } + HandlerOutcome::Processed } "partial" => { // partial: partial function application // For simulation, treat as identity/pass-through debug!("guppylang.partial at {node:?} - pass-through"); - self.propagate_all_inputs(hugr, node); - true + if !self.propagate_all_inputs(hugr, node) { + return HandlerOutcome::Defer; + } + HandlerOutcome::Processed } _ => { debug!("Unknown guppylang operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/prelude.rs b/crates/pecos-hugr/src/engine/handlers/prelude.rs index 5b9215f02..b77e3923b 100644 --- a/crates/pecos-hugr/src/engine/handlers/prelude.rs +++ b/crates/pecos-hugr/src/engine/handlers/prelude.rs @@ -24,57 +24,95 @@ use log::debug; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { /// Handle prelude extension operations. /// /// The prelude extension provides fundamental operations used across all HUGR programs. - pub(crate) fn handle_prelude_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_prelude_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing prelude operation: {op_name} at {node:?}"); match op_name { "load_nat" => { - // load_nat loads a bounded nat parameter into a usize runtime value. - // The value comes from the type arguments of the polymorphic instantiation. - // For now, we try to extract it from the extension op's args. + // load_nat loads a bounded nat parameter into a usize runtime + // value. In a monomorphic context the value is a concrete + // BoundedNat type arg on the op itself; inside a generic + // function body it is a type VARIABLE that must be resolved + // through the calling Call's instantiation type args. let op = hugr.get_optype(node); if let Some(ext_op) = op.as_extension_op() { - let args = ext_op.args(); - for arg in args { - // Look for BoundedNat type arg - if let tket::hugr::types::TypeArg::BoundedNat(n) = arg { - debug!("load_nat: found bounded nat value {n}"); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(*n)); - return true; + for arg in ext_op.args() { + match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => { + debug!("load_nat: found bounded nat value {n}"); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(*n)); + return HandlerOutcome::Processed; + } + tket::hugr::types::TypeArg::Variable(var) => { + if let Some(n) = self.resolve_call_type_arg(hugr, node, var.index()) + { + debug!( + "load_nat: resolved type variable {} to {n} via active call", + var.index() + ); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(n)); + return HandlerOutcome::Processed; + } + } + _ => {} } } - // If we can't find the value, log and return false debug!("load_nat: couldn't extract bounded nat value from args"); } - // Fallback: set a default value of 0 - debug!("load_nat: using default value 0"); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(0)); - true + // No fabricated default: a wrong nat here silently corrupts + // whatever consumes it (e.g. a loop bound). Defer so the + // engine's pending/retry mechanism (or, at completion, the + // stall accounting) surfaces the problem instead. + debug!("load_nat at {node:?}: value unresolved, deferring"); + HandlerOutcome::Defer } + "exit" => { + // prelude.exit "immediately halts a single shot's + // execution" -- a NORMAL termination the engine's + // drain-to-completion model cannot express mid-shot yet. + // Fault with a clear message instead of stalling with a + // starved-node report. + HandlerOutcome::Fault(format!( + "prelude.exit executed at {node:?}: mid-shot halt is not \ + supported by the engine yet" + )) + } "panic" => { - // Panic operation - for simulation, we log it and continue - // In a real execution, this would halt the program - debug!("prelude::panic encountered at {node:?}"); - // Mark as handled but don't crash the simulation - true + // An EXECUTED panic is a real runtime error on the taken + // path (guppy routes bounds/borrow/arithmetic failures + // here): raise a fatal fault instead of continuing with the + // panic's outputs unproduced, which either stalls with a + // misleading message or completes with corrupt results. + HandlerOutcome::Fault(format!( + "program panicked (prelude.panic executed at {node:?})" + )) } "print" => { // Print operation - for simulation, we just pass through debug!("prelude::print at {node:?}"); - self.propagate_all_inputs(hugr, node); - true + if !self.propagate_all_inputs(hugr, node) { + debug!("prelude::print at {node:?}: inputs not ready, deferring"); + return HandlerOutcome::Defer; + } + HandlerOutcome::Processed } "MakeTuple" => { @@ -84,13 +122,21 @@ impl HugrEngine { let op = hugr.get_optype(node); let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); + // A missing input means the value is not ready yet (or is a + // linear qubit handled structurally): defer instead of + // fabricating a default element, which would mark this node + // processed and let consumers (calls, blocks) fire with a + // garbage tuple. let mut elements = Vec::with_capacity(num_inputs); for port in 0..num_inputs { if let Some(value) = self.get_input_value(hugr, node, port) { elements.push(value); + } else if let Some(qubit_id) = self.get_input_qubit(hugr, node, port) { + // Linear payload: qubit flow is resolved structurally. + elements.push(ClassicalValue::QubitRef(qubit_id)); } else { - // Missing input - use a default - elements.push(ClassicalValue::Int(0)); + debug!("MakeTuple at {node:?}: input {port} not ready, deferring"); + return HandlerOutcome::Defer; } } @@ -101,7 +147,7 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 0), ClassicalValue::Tuple(elements)); - true + HandlerOutcome::Processed } "UnpackTuple" => { // UnpackTuple: 1 input (a tuple) -> N outputs (the elements) @@ -109,26 +155,68 @@ impl HugrEngine { let op = hugr.get_optype(node); let num_outputs = op.dataflow_signature().map_or(0, |sig| sig.output_count()); - if let Some(ClassicalValue::Tuple(elements)) = self.get_input_value(hugr, node, 0) { - for (port, value) in elements.into_iter().enumerate() { - if port < num_outputs { - self.wire_state.classical_values.insert((node, port), value); + // A tuple is a 1-variant sum, so accept a Sum payload the + // same way (e.g. a tuple that crossed a CFG/Call boundary as + // a tagged value). A missing input defers (marking the node + // processed without output would let consumers fire early). + match self.get_input_value(hugr, node, 0) { + Some( + ClassicalValue::Tuple(elements) + | ClassicalValue::Sum { + values: elements, .. + }, + ) => { + for (port, value) in elements.into_iter().enumerate() { + if port < num_outputs { + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((node, port), *qubit_id); + } + self.wire_state.classical_values.insert((node, port), value); + } + } + debug!("UnpackTuple at {node:?}: unpacked to {num_outputs} outputs"); + HandlerOutcome::Processed + } + Some(_) => { + // Single non-tuple value - pass through + debug!( + "UnpackTuple at {node:?}: input not a tuple, attempting pass-through" + ); + if !self.propagate_all_inputs(hugr, node) { + return HandlerOutcome::Defer; } + HandlerOutcome::Processed + } + None if self.get_input_qubit(hugr, node, 0).is_some() => { + // Linear (qubit) tuple: flow is resolved structurally. + let _ = self.propagate_all_inputs(hugr, node); + HandlerOutcome::Processed + } + None => { + debug!("UnpackTuple at {node:?}: input not ready, deferring"); + HandlerOutcome::Defer } - debug!("UnpackTuple at {node:?}: unpacked to {num_outputs} outputs"); - } else { - // Input not a tuple or not available - try pass-through as fallback - debug!("UnpackTuple at {node:?}: input not a tuple, attempting pass-through"); - self.propagate_all_inputs(hugr, node); } - true } + "Noop" | "Lift" | "Barrier" => { + // Genuine identity/annotation ops: pass values through; + // a missing input defers (marking processed would drop a + // late value permanently). + if !self.propagate_all_inputs(hugr, node) { + debug!("prelude::{op_name} at {node:?}: inputs not ready, deferring"); + return HandlerOutcome::Defer; + } + HandlerOutcome::Processed + } _ => { - debug!("Unknown prelude operation: {op_name}"); - // For unknown ops, try to propagate inputs as a pass-through - self.propagate_all_inputs(hugr, node); - true + // Unknown op: defer so it surfaces in the completion-time + // stall report -- treating an op the engine knows nothing + // about as an identity wire fabricates semantics. + debug!("Unknown prelude operation: {op_name} at {node:?}, deferring"); + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index 7d6fe50c8..76b8c6eff 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -27,174 +27,215 @@ use pecos_core::QubitId; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::{ClassicalValue, FutureState, RngContextId, RngContextState}; impl HugrEngine { /// Handle tket.qsystem operations (lazy measurements, barriers, etc.). #[allow(clippy::too_many_lines)] // Operation dispatch is inherently large - pub(crate) fn handle_qsystem_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_qsystem_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.qsystem operation: {op_name} at {node:?}"); match op_name { "LazyMeasure" => { // LazyMeasure: Qubit -> Future // Queue the measurement and create a Future handle - if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { - // Queue measurement - self.message_builder.mz(&[qubit_id.0]); - let measurement_index = self.measurement_state.mappings.len(); - self.measurement_state.mappings.push((node, qubit_id)); - - // Create a Future - let future_id = self.extension_state.next_future_id; - self.extension_state.next_future_id += 1; - self.extension_state.futures.insert( - future_id, - FutureState::Pending { - measurement_node: node, - qubit: qubit_id, - measurement_index, - }, - ); - - // Store Future value on output port 0 - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Future(future_id)); + let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { + debug!("LazyMeasure at {node:?}: qubit not resolved, deferring"); + return HandlerOutcome::Defer; + }; + // Queue measurement + self.message_builder.mz(&[qubit_id.0]); + let measurement_index = self.measurement_state.mappings.len(); + self.measurement_state.mappings.push((node, qubit_id)); + + // Create a Future + let future_id = self.extension_state.next_future_id; + self.extension_state.next_future_id += 1; + self.extension_state.futures.insert( + future_id, + FutureState::Pending { + measurement_node: node, + qubit: qubit_id, + measurement_index, + int_valued: false, + }, + ); + + // Store Future value on output port 0 + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Future(future_id)); - debug!("LazyMeasure on qubit {qubit_id:?}, created future {future_id}"); - } - true + debug!("LazyMeasure on qubit {qubit_id:?}, created future {future_id}"); + HandlerOutcome::Processed } "LazyMeasureReset" => { // LazyMeasureReset: Qubit -> (Qubit, Future) - if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { - // Queue measurement - self.message_builder.mz(&[qubit_id.0]); - let measurement_index = self.measurement_state.mappings.len(); - self.measurement_state.mappings.push((node, qubit_id)); - - // Queue reset - self.message_builder.pz(&[qubit_id.0]); - - // Create a Future - let future_id = self.extension_state.next_future_id; - self.extension_state.next_future_id += 1; - self.extension_state.futures.insert( - future_id, - FutureState::Pending { - measurement_node: node, - qubit: qubit_id, - measurement_index, - }, - ); - - // Output port 0: qubit, Output port 1: Future - self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - self.wire_state - .classical_values - .insert((node, 1), ClassicalValue::Future(future_id)); + let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { + debug!("LazyMeasureReset at {node:?}: qubit not resolved, deferring"); + return HandlerOutcome::Defer; + }; + // Queue measurement + self.message_builder.mz(&[qubit_id.0]); + let measurement_index = self.measurement_state.mappings.len(); + self.measurement_state.mappings.push((node, qubit_id)); + + // Queue reset + self.message_builder.pz(&[qubit_id.0]); + + // Create a Future + let future_id = self.extension_state.next_future_id; + self.extension_state.next_future_id += 1; + self.extension_state.futures.insert( + future_id, + FutureState::Pending { + measurement_node: node, + qubit: qubit_id, + measurement_index, + int_valued: false, + }, + ); + + // Output port 0: qubit, Output port 1: Future + self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Future(future_id)); - debug!("LazyMeasureReset on qubit {qubit_id:?}, created future {future_id}"); - } - true + debug!("LazyMeasureReset on qubit {qubit_id:?}, created future {future_id}"); + HandlerOutcome::Processed } "LazyMeasureLeaked" => { // LazyMeasureLeaked: Qubit -> Future // Same as LazyMeasure but result can be 0, 1, or 2 (leaked) - if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { - self.message_builder.mz(&[qubit_id.0]); - let measurement_index = self.measurement_state.mappings.len(); - self.measurement_state.mappings.push((node, qubit_id)); - - let future_id = self.extension_state.next_future_id; - self.extension_state.next_future_id += 1; - self.extension_state.futures.insert( - future_id, - FutureState::Pending { - measurement_node: node, - qubit: qubit_id, - measurement_index, - }, - ); + let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { + debug!("LazyMeasureLeaked at {node:?}: qubit not resolved, deferring"); + return HandlerOutcome::Defer; + }; + self.message_builder.mz(&[qubit_id.0]); + let measurement_index = self.measurement_state.mappings.len(); + self.measurement_state.mappings.push((node, qubit_id)); + + let future_id = self.extension_state.next_future_id; + self.extension_state.next_future_id += 1; + self.extension_state.futures.insert( + future_id, + FutureState::Pending { + measurement_node: node, + qubit: qubit_id, + measurement_index, + int_valued: true, + }, + ); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Future(future_id)); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Future(future_id)); - debug!("LazyMeasureLeaked on qubit {qubit_id:?}, created future {future_id}"); - } - true + debug!("LazyMeasureLeaked on qubit {qubit_id:?}, created future {future_id}"); + HandlerOutcome::Processed } "MeasureReset" => { // MeasureReset: Qubit -> (Qubit, bool) // Atomic measure + reset (not lazy) - if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { - self.message_builder.mz(&[qubit_id.0]); - self.measurement_state.mappings.push((node, qubit_id)); + let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { + debug!("MeasureReset at {node:?}: qubit not resolved, deferring"); + return HandlerOutcome::Defer; + }; + self.message_builder.mz(&[qubit_id.0]); + self.measurement_state.mappings.push((node, qubit_id)); - // Queue reset - self.message_builder.pz(&[qubit_id.0]); + // Queue reset + self.message_builder.pz(&[qubit_id.0]); - // Track measurement output wire - self.measurement_state.output_wires.insert(node, (node, 1)); + // Track measurement output wire + self.measurement_state.output_wires.insert(node, (node, 1)); - // Output port 0: qubit - self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); + // Output port 0: qubit + self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - debug!("MeasureReset on qubit {qubit_id:?}"); - } - true + debug!("MeasureReset on qubit {qubit_id:?}"); + HandlerOutcome::Processed } "RuntimeBarrier" | "StateResult" => { // Pass-through operations: input array = output array // For simulation, these are no-ops - // Propagate qubit arrays if present - self.propagate_qubit_array(hugr, node); + if !self.propagate_qubit_array(hugr, node) { + debug!("{op_name} at {node:?}: input not resolved, deferring"); + return HandlerOutcome::Defer; + } debug!("{op_name} at {node:?} (no-op for simulation)"); - true + HandlerOutcome::Processed } "TryQAlloc" => { - // TryQAlloc: () -> Sum<(), Qubit> - // For simulation, always succeed and allocate a qubit + // TryQAlloc: () -> Option + // For simulation, always succeed and allocate a qubit. The + // value must be a REAL Sum carrying the qubit payload: + // case-input propagation unpacks payloads from Sum values, + // and a bare scalar loses the allocated qubit (falling into + // implicit re-allocation downstream). let qubit_id = QubitId::from(self.wire_state.next_qubit_id); self.wire_state.next_qubit_id += 1; - // Output on port 0 (Sum type, tag 1 = success with qubit) self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - // Store Sum tag = 1 (success) for control flow - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(1)); + self.wire_state.classical_values.insert( + (node, 0), + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::QubitRef(qubit_id)], + }, + ); debug!("TryQAlloc created qubit {qubit_id:?}"); - true + HandlerOutcome::Processed } "Reset" | "Rz" | "PhasedX" | "ZZPhase" | "Measure" | "QFree" => { // These are handled as quantum ops (via hugr_op_to_gate_type) // Return false to let the quantum op handler process them - false + HandlerOutcome::Defer } _ => { debug!("Unknown tket.qsystem operation: {op_name}"); - false + HandlerOutcome::Defer } } } /// Handle `tket.qsystem.random` operations for random number generation. #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] - pub(crate) fn handle_random_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_random_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.qsystem.random operation: {op_name} at {node:?}"); match op_name { "NewRNGContext" => { - // NewRNGContext: int<64> -> RNGContext - // Create a new RNG context with the given seed - let seed = self + // NewRNGContext: int<64> -> Option + // Create a new RNG context with the given seed. The + // signature returns an option (None on a second call); this + // engine has no global-context restriction, so it always + // produces Some. + // The u64 seed must be read as the canonical bit pattern: + // as_uint rejects negative storage, so a valid seed >= 2^63 + // (stored sign-extended) would defer forever. + #[allow(clippy::cast_sign_loss)] + let Some(seed) = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()) - .unwrap_or(0); + .and_then(|v| v.as_int()) + .map(|v| v as u64) + else { + debug!("NewRNGContext at {node:?}: seed not ready, deferring"); + return HandlerOutcome::Defer; + }; let ctx_id = self.extension_state.next_rng_context_id; self.extension_state.next_rng_context_id += 1; @@ -203,104 +244,138 @@ impl HugrEngine { .rng_contexts .insert(ctx_id, RngContextState::new(seed)); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); + self.wire_state.classical_values.insert( + (node, 0), + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::RngContext(ctx_id)], + }, + ); - debug!("NewRNGContext with seed {seed} -> context {ctx_id}"); - true + debug!("NewRNGContext with seed {seed} -> Some(context {ctx_id})"); + HandlerOutcome::Processed } "DeleteRNGContext" => { // DeleteRNGContext: RNGContext -> () // Clean up an RNG context - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::RngContext(ctx_id) = value - { - self.extension_state.rng_contexts.remove(&ctx_id); - debug!("DeleteRNGContext: removed context {ctx_id}"); - } - true + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("DeleteRNGContext at {node:?}: context not ready, deferring"); + return HandlerOutcome::Defer; + }; + self.extension_state.rng_contexts.remove(&ctx_id); + debug!("DeleteRNGContext: removed context {ctx_id}"); + HandlerOutcome::Processed } "RandomFloat" => { - // RandomFloat: RNGContext -> (RNGContext, float64) + // RandomFloat: RNGContext -> (float64, RNGContext) // Generate a random float in [0, 1) - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::RngContext(ctx_id) = value - { - let random_float = self.generate_random_float(ctx_id); - - // Output port 0: RNGContext (pass through) - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); - // Output port 1: random float - self.wire_state - .classical_values - .insert((node, 1), ClassicalValue::Float(random_float)); + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("RandomFloat at {node:?}: context not ready, deferring"); + return HandlerOutcome::Defer; + }; + let random_float = self.generate_random_float(ctx_id); + + // Value first, context second, per the extension signature + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Float(random_float)); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::RngContext(ctx_id)); - debug!("RandomFloat: generated {random_float}"); - } - true + debug!("RandomFloat: generated {random_float}"); + HandlerOutcome::Processed } "RandomInt" => { - // RandomInt: RNGContext -> (RNGContext, int<32>) + // RandomInt: RNGContext -> (int<32>, RNGContext) // Generate a random 32-bit integer - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::RngContext(ctx_id) = value - { - let random_int = self.generate_random_u64(ctx_id) as i64; + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("RandomInt at {node:?}: context not ready, deferring"); + return HandlerOutcome::Defer; + }; + // The output is int<5> (32-bit): canonical storage is the + // sign-extended low 32 bits, per the engine-wide width + // convention (a raw zero-extended u32 would misread in every + // signed consumer). + #[allow(clippy::cast_possible_truncation)] // intentional 32-bit mask + let random_int = i64::from((self.generate_random_u64(ctx_id) as u32).cast_signed()); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); - self.wire_state - .classical_values - .insert((node, 1), ClassicalValue::Int(random_int)); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(random_int)); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::RngContext(ctx_id)); - debug!("RandomInt: generated {random_int}"); - } - true + debug!("RandomInt: generated {random_int}"); + HandlerOutcome::Processed } "RandomIntBounded" => { - // RandomIntBounded: (RNGContext, int<32>) -> (RNGContext, int<32>) + // RandomIntBounded: (RNGContext, int<32>) -> (int<32>, RNGContext) // Generate a random integer in [0, bound) - let ctx_value = self.get_input_value(hugr, node, 0); - let bound = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_int()) - .unwrap_or(1) - .max(1) as u64; - - if let Some(ClassicalValue::RngContext(ctx_id)) = ctx_value { - let random_val = self.generate_random_u64(ctx_id) % bound; + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("RandomIntBounded at {node:?}: context not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(bound) = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()) + else { + debug!("RandomIntBounded at {node:?}: bound not ready, deferring"); + return HandlerOutcome::Defer; + }; + // The bound is UNSIGNED int<5>: reinterpret the canonical + // (sign-extended) storage as its 32-bit pattern -- a bound + // >= 2^31 stores negative but names a valid nonempty range. + #[allow(clippy::cast_sign_loss)] + let bound = (bound as u64) & 0xFFFF_FFFF; + if bound == 0 { + // [0, 0) is empty: there is no value this op could + // produce, so clamping would fabricate a result. + return HandlerOutcome::Fault(format!( + "RandomIntBounded at {node:?}: bound 0 names an empty range" + )); + } + let random_val = self.generate_random_u64(ctx_id) % bound; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); - self.wire_state - .classical_values - .insert((node, 1), ClassicalValue::Int(random_val as i64)); + #[allow(clippy::cast_possible_truncation)] + self.wire_state.classical_values.insert( + (node, 0), + ClassicalValue::Int(i64::from((random_val as u32).cast_signed())), + ); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::RngContext(ctx_id)); - debug!("RandomIntBounded({bound}): generated {random_val}"); - } - true + debug!("RandomIntBounded({bound}): generated {random_val}"); + HandlerOutcome::Processed } "RandomAdvance" => { // RandomAdvance: (RNGContext, int<64>) -> RNGContext // Advance the RNG state by delta steps (can be negative for backtracking) - let ctx_value = self.get_input_value(hugr, node, 0); - let delta = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_int()) - .unwrap_or(0); - - if let Some(ClassicalValue::RngContext(ctx_id)) = ctx_value { - // Advance the RNG state by |delta| steps - // Note: For simplicity, we only support forward advancement - // Negative delta would require storing history which we don't do - let steps = delta.unsigned_abs(); - for _ in 0..steps { - self.generate_random_u64(ctx_id); + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("RandomAdvance at {node:?}: context not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(delta) = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()) + else { + debug!("RandomAdvance at {node:?}: delta not ready, deferring"); + return HandlerOutcome::Defer; + }; + + { + // The spec advances OR BACKTRACKS by delta. xorshift64 + // is linear over GF(2), so both directions jump in + // O(log delta) -- no step loop, no ceiling. + if let Some(ctx) = self.extension_state.rng_contexts.get_mut(&ctx_id) { + if delta >= 0 { + ctx.jump(delta.unsigned_abs().into()); + } else { + ctx.jump_back(delta.unsigned_abs()); + } } self.wire_state @@ -309,11 +384,11 @@ impl HugrEngine { debug!("RandomAdvance: advanced by {delta} steps"); } - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.qsystem.random operation: {op_name}"); - false + HandlerOutcome::Defer } } } @@ -337,7 +412,12 @@ impl HugrEngine { } /// Handle `tket.qsystem.utils` operations. - pub(crate) fn handle_utils_op(&mut self, _hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_utils_op( + &mut self, + _hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.qsystem.utils operation: {op_name} at {node:?}"); if op_name == "GetCurrentShot" { @@ -349,10 +429,10 @@ impl HugrEngine { ); debug!("GetCurrentShot: {}", self.extension_state.current_shot); - true + HandlerOutcome::Processed } else { debug!("Unknown tket.qsystem.utils operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/quantum.rs b/crates/pecos-hugr/src/engine/handlers/quantum.rs index 8eb839872..34abcd678 100644 --- a/crates/pecos-hugr/src/engine/handlers/quantum.rs +++ b/crates/pecos-hugr/src/engine/handlers/quantum.rs @@ -24,9 +24,11 @@ //! not through these extension handlers. use log::debug; +use pecos_quantum::hugr_convert::try_extract_rotation_angle; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { @@ -40,35 +42,39 @@ impl HugrEngine { hugr: &Hugr, node: Node, op_name: &str, - ) -> bool { + ) -> HandlerOutcome { debug!("Processing tket.quantum non-gate operation: {op_name} at {node:?}"); match op_name { "symbolic_angle" => { // symbolic_angle: () -> rotation - // Creates a rotation from a symbolic expression (sympy string parameter) - // For simulation, we try to parse simple numeric expressions + // Creates a rotation from a symbolic expression (sympy + // string parameter). Simple numeric expressions parse; an + // UNRESOLVED symbolic angle must fault -- defaulting to 0.0 + // silently simulates the identity instead of the program. let op = hugr.get_optype(node); - if let Some(ext_op) = op.as_extension_op() { + let parsed = op.as_extension_op().and_then(|ext_op| { let debug_str = format!("{ext_op:?}"); - // Try to extract the symbolic expression from parameters - let angle = Self::parse_symbolic_angle(&debug_str); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Rotation(angle)); - debug!("symbolic_angle: parsed angle = {angle} half-turns"); - } else { - // Default to 0 if we can't parse - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Rotation(0.0)); - debug!("symbolic_angle: defaulting to 0"); + Self::parse_symbolic_angle(&debug_str) + }); + match parsed { + Some(angle) => { + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Rotation(angle)); + debug!("symbolic_angle: parsed angle = {angle} half-turns"); + HandlerOutcome::Processed + } + None => HandlerOutcome::Fault(format!( + "symbolic_angle at {node:?}: unresolved symbolic expression \ + (cannot be simulated; substituting 0 would silently run the \ + wrong circuit)" + )), } - true } // Quantum gates are handled via the quantum ops path, not here - // Return false to let them fall through to the gate handling - _ => false, + // -- defer so they fall through to the gate handling + _ => HandlerOutcome::Defer, } } @@ -78,44 +84,47 @@ impl HugrEngine { /// - Numeric literals: "0.5", "1.0", "-0.25" /// - Pi expressions: "pi", "pi/2", "pi/4", "2*pi" /// - Fractions: "1/2", "1/4" - pub(crate) fn parse_symbolic_angle(debug_str: &str) -> f64 { + /// + /// Returns None for expressions it cannot evaluate -- the caller must + /// fail loud, never substitute a default angle. + pub(crate) fn parse_symbolic_angle(debug_str: &str) -> Option { // Look for quoted string content that might contain the expression if let Some(expr) = Self::extract_string_from_debug(debug_str) { let expr = expr.trim().to_lowercase(); // Try parsing as a simple float if let Ok(val) = expr.parse::() { - return val; + return Some(val); } // Handle pi expressions (angles in half-turns, so pi = 1.0 half-turn) if expr == "pi" { - return 1.0; + return Some(1.0); } if expr == "-pi" { - return -1.0; + return Some(-1.0); } if expr == "2*pi" || expr == "2pi" { - return 2.0; + return Some(2.0); } // Handle pi/n expressions if let Some(rest) = expr.strip_prefix("pi/") && let Ok(divisor) = rest.parse::() { - return 1.0 / divisor; + return Some(1.0 / divisor); } if let Some(rest) = expr.strip_prefix("-pi/") && let Ok(divisor) = rest.parse::() { - return -1.0 / divisor; + return Some(-1.0 / divisor); } // Handle n*pi expressions if let Some(rest) = expr.strip_suffix("*pi") && let Ok(multiplier) = rest.parse::() { - return multiplier; + return Some(multiplier); } // Handle simple fractions like 1/2, 1/4 @@ -123,61 +132,87 @@ impl HugrEngine { && let (Ok(num), Ok(denom)) = (num_str.parse::(), denom_str.parse::()) && denom != 0.0 { - return num / denom; + return Some(num / denom); } - debug!("Could not parse symbolic angle expression: '{expr}', defaulting to 0"); + debug!("Could not parse symbolic angle expression: '{expr}'"); } - 0.0 + None } /// Handle `tket.rotation` operations. - pub(crate) fn handle_rotation_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + /// + /// Missing inputs DEFER the node (return `false` so the engine's + /// pending/retry mechanism picks it up) instead of fabricating a + /// silent 0.0 rotation. `from_halfturns` additionally falls back to + /// the static angle tracer: some angle chains are only evaluable + /// statically (e.g. guppy expression angles like `pi / 4` lower to + /// Call nodes the runtime does not execute). If neither source can + /// produce the value, the node stays deferred and the gate consumer + /// (`resolve_rotation_angle`) fails loud if the statically extracted + /// gate angle is also missing. + pub(crate) fn handle_rotation_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.rotation operation: {op_name} at {node:?}"); match op_name { "from_halfturns" | "from_halfturns_unchecked" => { // from_halfturns: float64 -> Rotation - // Convert a float (in half-turns) to a Rotation type - let halfturns = self + // Convert a float (in half-turns) to a Rotation type. + // try_extract_rotation_angle traces this node's input 0 (0 + // qubit inputs) and returns full turns; halfturns = 2 * turns. + let Some(halfturns) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - .unwrap_or(0.0); + .or_else(|| try_extract_rotation_angle(hugr, node, 0).map(|turns| turns * 2.0)) + else { + debug!("tket.rotation.{op_name} at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; self.wire_state .classical_values .insert((node, 0), ClassicalValue::Rotation(halfturns)); debug!("tket.rotation.from_halfturns: {halfturns}"); - true + HandlerOutcome::Processed } "to_halfturns" => { // to_halfturns: Rotation -> float64 // Convert a Rotation to a float (in half-turns) - let halfturns = self + let Some(halfturns) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_rotation()) - .unwrap_or(0.0); + else { + debug!("tket.rotation.to_halfturns at {node:?}: input not ready, deferring"); + return HandlerOutcome::Defer; + }; self.wire_state .classical_values .insert((node, 0), ClassicalValue::Float(halfturns)); debug!("tket.rotation.to_halfturns: {halfturns}"); - true + HandlerOutcome::Processed } "radd" => { // radd: (Rotation, Rotation) -> Rotation // Add two rotations let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_rotation()) - .unwrap_or(0.0); + .and_then(|v| v.as_rotation()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_rotation()) - .unwrap_or(0.0); + .and_then(|v| v.as_rotation()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.rotation.radd at {node:?}: inputs not ready, deferring"); + return HandlerOutcome::Defer; + }; // Rotation addition, normalized to [0, 2) half-turns let sum = (a + b).rem_euclid(2.0); @@ -187,48 +222,34 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Rotation(sum)); debug!("tket.rotation.radd: {a} + {b} = {sum}"); - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.rotation operation: {op_name}"); - false + HandlerOutcome::Defer } } } /// Handle `tket.modifier` operations for gate modifiers. - pub(crate) fn handle_modifier_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_modifier_op(node: Node, op_name: &str) -> HandlerOutcome { debug!("Processing tket.modifier operation: {op_name} at {node:?}"); - // Gate modifiers change how gates are applied. - // For simulation, we track these as metadata but the actual gate - // application happens in the quantum backend. + // Gate modifiers (control/dagger/power) CHANGE the semantics of the + // operation they wrap. The engine has no machinery to apply that + // change: passing the qubits through and reporting Processed would + // silently execute the UNMODIFIED gate -- wrong answers, no error. + // Fail loud until modifier semantics are actually implemented. match op_name { - "ControlModifier" => { - // ControlModifier adds quantum control to an operation - // Input: control qubit(s) + operation - // For simulation, this is handled by the quantum backend - self.propagate_qubit_array(hugr, node); - debug!("ControlModifier at {node:?} (handled by quantum backend)"); - true - } - "DaggerModifier" => { - // DaggerModifier applies the inverse/adjoint of an operation - // For simulation, this is handled by the quantum backend - self.propagate_qubit_array(hugr, node); - debug!("DaggerModifier at {node:?} (handled by quantum backend)"); - true - } - "PowerModifier" => { - // PowerModifier raises an operation to a power - // For simulation, this is handled by the quantum backend - self.propagate_qubit_array(hugr, node); - debug!("PowerModifier at {node:?} (handled by quantum backend)"); - true + "ControlModifier" | "DaggerModifier" | "PowerModifier" => { + HandlerOutcome::Fault(format!( + "tket.modifier {op_name} at {node:?} is not supported by the HUGR \ + engine; executing the wrapped operation unmodified would be wrong" + )) } _ => { debug!("Unknown tket.modifier operation: {op_name}"); - false + HandlerOutcome::Defer } } } @@ -239,16 +260,25 @@ impl HugrEngine { hugr: &Hugr, node: Node, op_name: &str, - ) -> bool { + ) -> HandlerOutcome { debug!("Processing tket.global_phase operation: {op_name} at {node:?}"); if op_name == "global_phase" { // global_phase: Rotation -> () - // Add global phase to the circuit - let phase = self + // Add global phase to the circuit. No silent zero default: the + // rotation may simply not have resolved YET, and folding it to + // 0 would drop the phase without any visible failure. Note the + // blast radius: global phase is unobservable in every result + // this engine surfaces, yet a rotation that NEVER resolves + // fails the whole program via the stall report -- accepted + // (defer-forever bugs must stay loud), revisit if it bites. + let Some(phase) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_rotation()) - .unwrap_or(0.0); + else { + debug!("tket.global_phase at {node:?}: rotation not resolved, deferring"); + return HandlerOutcome::Defer; + }; // Accumulate global phase (normalized to [0, 2)) self.extension_state.global_phase = @@ -258,10 +288,10 @@ impl HugrEngine { "tket.global_phase: added {phase}, total = {}", self.extension_state.global_phase ); - true + HandlerOutcome::Processed } else { debug!("Unknown tket.global_phase operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/result.rs b/crates/pecos-hugr/src/engine/handlers/result.rs index d5e76704b..1cb403393 100644 --- a/crates/pecos-hugr/src/engine/handlers/result.rs +++ b/crates/pecos-hugr/src/engine/handlers/result.rs @@ -25,12 +25,18 @@ use log::debug; use tket::hugr::{Hugr, HugrView, Node, NodeIndex}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::{CapturedResult, ClassicalValue, ResultValue}; impl HugrEngine { /// Handle tket.result operations for capturing output values. #[allow(clippy::too_many_lines)] - pub(crate) fn handle_result_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_result_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.result operation: {op_name} at {node:?}"); // Get the label from the first input port (typically the operation has a label parameter) @@ -48,11 +54,11 @@ impl HugrEngine { label, value: ResultValue::Bool(b), }); - true + HandlerOutcome::Processed } else { // Input not ready - defer processing debug!("result_bool at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } "result_int" => { @@ -64,20 +70,33 @@ impl HugrEngine { value: ResultValue::Int(i), }); debug!("Captured result_int: {i}"); + HandlerOutcome::Processed + } else { + // Marking the node processed without capturing would + // silently drop the result; defer until the value arrives. + debug!("result_int at {node:?}: deferring - input not ready"); + HandlerOutcome::Defer } - true } "result_uint" => { + // Reinterpret the canonical (sign-extended) bit pattern: + // as_uint REJECTS negative storage, but a canonical u64 + // >= 2^63 stores negative -- rejecting it defers the result + // op forever and kills the shot in the stall report. + #[allow(clippy::cast_sign_loss)] if let Some(value) = self.get_input_value(hugr, node, 0) - && let Some(u) = value.as_uint() + && let Some(u) = value.as_int().map(|v| v as u64) { self.captured_results.push(CapturedResult { label, value: ResultValue::UInt(u), }); debug!("Captured result_uint: {u}"); + HandlerOutcome::Processed + } else { + debug!("result_uint at {node:?}: deferring - input not ready"); + HandlerOutcome::Defer } - true } "result_f64" => { if let Some(value) = self.get_input_value(hugr, node, 0) @@ -88,80 +107,112 @@ impl HugrEngine { value: ResultValue::Float(f), }); debug!("Captured result_f64: {f}"); + HandlerOutcome::Processed + } else { + debug!("result_f64 at {node:?}: deferring - input not ready"); + HandlerOutcome::Defer } - true } "result_array_bool" => { + // Elements are converted with map (not filter_map): a single + // unconvertible element defers the whole capture instead of + // silently shortening the array. if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() + && let Some(bools) = arr + .iter() + .map(ClassicalValue::as_bool) + .collect::>>() { - let bools: Vec = arr.iter().filter_map(ClassicalValue::as_bool).collect(); self.captured_results.push(CapturedResult { label, value: ResultValue::ArrayBool(bools), }); + HandlerOutcome::Processed + } else { + debug!("result_array_bool at {node:?}: deferring - input not ready"); + HandlerOutcome::Defer } - true } "result_array_int" => { if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() + && let Some(ints) = arr + .iter() + .map(ClassicalValue::as_int) + .collect::>>() { - let ints: Vec = arr.iter().filter_map(ClassicalValue::as_int).collect(); self.captured_results.push(CapturedResult { label, value: ResultValue::ArrayInt(ints), }); + HandlerOutcome::Processed + } else { + debug!("result_array_int at {node:?}: deferring - input not ready"); + HandlerOutcome::Defer } - true } "result_array_uint" => { + // Bit-reinterpret canonical storage; see result_uint. + #[allow(clippy::cast_sign_loss)] if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() + && let Some(uints) = arr + .iter() + .map(|v| v.as_int().map(|i| i as u64)) + .collect::>>() { - let uints: Vec = arr.iter().filter_map(ClassicalValue::as_uint).collect(); self.captured_results.push(CapturedResult { label, value: ResultValue::ArrayUInt(uints), }); + HandlerOutcome::Processed + } else { + debug!("result_array_uint at {node:?}: deferring - input not ready"); + HandlerOutcome::Defer } - true } "result_array_f64" => { if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() + && let Some(floats) = arr + .iter() + .map(ClassicalValue::as_float) + .collect::>>() { - let floats: Vec = - arr.iter().filter_map(ClassicalValue::as_float).collect(); self.captured_results.push(CapturedResult { label, value: ResultValue::ArrayFloat(floats), }); + HandlerOutcome::Processed + } else { + debug!("result_array_f64 at {node:?}: deferring - input not ready"); + HandlerOutcome::Defer } - true } _ => { debug!("Unknown tket.result operation: {op_name}"); - false + HandlerOutcome::Defer } } } - /// Extract result label from operation parameters. + /// Extract the result label from the op's TYPED String arg. + /// + /// tket.result ops carry the user label as a String type arg; reading + /// it directly replaces the old Debug-output scrape, whose quote + /// heuristics also rejected legitimate labels containing "result", + /// "Op", or "Report". #[allow(clippy::unused_self)] // Consistent with other handler methods; may use self in future pub(crate) fn extract_result_label(&self, hugr: &Hugr, node: Node, op_name: &str) -> String { - // Try to extract label from the ExtensionOp's debug representation - // The debug format typically includes the label as a string parameter let op = hugr.get_optype(node); if let Some(ext_op) = op.as_extension_op() { - let debug_str = format!("{ext_op:?}"); - // Look for quoted string patterns that might be labels - // Common patterns: "label", label="value", or ("label", ...) - if let Some(label) = Self::extract_string_from_debug(&debug_str) - && !label.is_empty() - && label != op_name - { - return label; + for arg in ext_op.args() { + if let tket::hugr::types::TypeArg::String(label) = arg + && !label.is_empty() + { + return label.clone(); + } } } // Fallback: use node ID as label diff --git a/crates/pecos-hugr/src/engine/handlers/wasm.rs b/crates/pecos-hugr/src/engine/handlers/wasm.rs index ded62ade2..5b3df1b8e 100644 --- a/crates/pecos-hugr/src/engine/handlers/wasm.rs +++ b/crates/pecos-hugr/src/engine/handlers/wasm.rs @@ -21,11 +21,17 @@ use log::debug; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { /// Handle `tket.wasm` operations for WebAssembly integration. - pub(crate) fn handle_wasm_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_wasm_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.wasm operation: {op_name} at {node:?}"); // WASM operations are for hybrid classical-quantum computation. @@ -40,21 +46,23 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::UInt(0)); debug!("tket.wasm.get_context: stub (no WASM support)"); - true + HandlerOutcome::Processed } "dispose_context" | "DisposeContext" => { // dispose_context: WasmContext -> () // Clean up WASM context (no-op for stub) debug!("tket.wasm.dispose_context: stub (no WASM support)"); - true + HandlerOutcome::Processed } "call" | "Call" => { // call: (WasmContext, ...) -> (WasmContext, ...) // Call a WASM function // Stub: pass through inputs to outputs - self.propagate_all_inputs(hugr, node); + if !self.propagate_all_inputs(hugr, node) { + return HandlerOutcome::Defer; + } debug!("tket.wasm.call: stub (no WASM support)"); - true + HandlerOutcome::Processed } "lookup_by_id" | "LookupById" => { // lookup_by_id: (WasmContext, int) -> (WasmContext, WasmFunc) @@ -66,7 +74,7 @@ impl HugrEngine { .classical_values .insert((node, 1), ClassicalValue::UInt(0)); debug!("tket.wasm.lookup_by_id: stub (no WASM support)"); - true + HandlerOutcome::Processed } "lookup_by_name" | "LookupByName" => { // lookup_by_name: (WasmContext, String) -> (WasmContext, WasmFunc) @@ -78,7 +86,7 @@ impl HugrEngine { .classical_values .insert((node, 1), ClassicalValue::UInt(0)); debug!("tket.wasm.lookup_by_name: stub (no WASM support)"); - true + HandlerOutcome::Processed } "read_result" | "ReadResult" => { // read_result: WasmResult -> value @@ -87,11 +95,11 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::Int(0)); debug!("tket.wasm.read_result: stub (no WASM support)"); - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.wasm operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index 9e5edd155..ee595b05a 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -25,7 +25,7 @@ use log::debug; use pecos_core::QubitId; use pecos_core::gate_type::GateType; -use tket::hugr::ops::OpType; +use tket::hugr::ops::{OpType, Value}; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; @@ -33,6 +33,12 @@ use crate::engine::analysis::get_container_type; use crate::engine::types::{ClassicalValue, ContainerType, QuantumOp, WireKey}; impl HugrEngine { + /// Ceiling on flattened-DFG boundary hops when tracing a value or + /// qubit backwards. Valid HUGRs bound this by nesting depth (the + /// hierarchy is a tree and each region a DAG); the cap keeps a + /// malformed graph from recursing without limit. + const MAX_DFG_TRACE_DEPTH: usize = 64; + /// Trace through an Input node to find the actual source wire. /// /// When processing nodes inside containers (DFG, Case, `FuncDefn`, etc.), @@ -66,19 +72,26 @@ impl HugrEngine { output_port } ContainerType::Conditional => { - // Conditional: Port 0 of Input unpacks Sum fields; subsequent ports are data - // This is complex - the Input node outputs come from unpacking the Sum - // For now, skip port 0 (Sum unpacking) and map other ports - if output_port == 0 { - debug!("Skipping Conditional Sum unpacking (port 0)"); - return None; - } - // Data ports start at container input port 1 (after control) - output_port // Actually maps to same port since control is separate + // Conditional: the Input row is [sum payload..., other data...] + // while the container's port row is [sum, other data...]. A + // direct port-N -> port-N mapping is only correct when the + // selected variant's payload arity is exactly 1; for any + // other arity it silently reads the WRONG container port. + // Case expansion populates these Input ports explicitly, so + // decline to trace rather than guess. + debug!("Conditional Input ports are populated by case expansion; not tracing"); + return None; } ContainerType::TailLoop => { - // TailLoop is complex - inputs come from both initial values and CONTINUE tag - // For simplicity, use direct mapping + // Direct mapping reads the loop node's own input wire: the + // iteration-0 value. Guppy carries every live value in the + // varying row, so past the first Continue this CAN serve a + // stale generation for a mutated value -- but the corpus + // relies on it for carried-but-invariant values whose + // continue propagation gapped on a pending measurement. + // The proper fix is completeness of the population paths + // (continue repropagation after measurement rounds), not + // starving this fallback. output_port } ContainerType::Call => { @@ -152,19 +165,31 @@ impl HugrEngine { /// Propagate all input values to corresponding output ports. /// /// This is used for pass-through operations that don't modify values. - pub(crate) fn propagate_all_inputs(&mut self, hugr: &Hugr, node: Node) { + /// Copy every input wire to the corresponding output wire (pass-through + /// ops). Returns false when some port had NEITHER a classical value nor + /// a qubit mapping: the caller must DEFER -- marking the op processed + /// would permanently drop the late value (pass-throughs have no retry + /// of their own). + #[must_use] + pub(crate) fn propagate_all_inputs(&mut self, hugr: &Hugr, node: Node) -> bool { use tket::hugr::ops::OpTrait; let op = hugr.get_optype(node); let num_outputs = op.dataflow_signature().map_or(0, |sig| sig.output_count()); + let mut complete = true; for port in 0..num_outputs { + let mut found = false; if let Some(value) = self.get_input_value(hugr, node, port) { self.wire_state.classical_values.insert((node, port), value); + found = true; } if let Some(qubit) = self.get_input_qubit(hugr, node, port) { self.wire_state.wire_to_qubit.insert((node, port), qubit); + found = true; } + complete &= found; } + complete } /// Get a classical value from an input port. @@ -177,6 +202,20 @@ impl HugrEngine { node: Node, port: usize, ) -> Option { + self.get_input_value_depth(hugr, node, port, 0) + } + + fn get_input_value_depth( + &self, + hugr: &Hugr, + node: Node, + port: usize, + depth: usize, + ) -> Option { + if depth > Self::MAX_DFG_TRACE_DEPTH { + debug!("get_input_value({node:?}, {port}): DFG trace depth exceeded"); + return None; + } let in_port = IncomingPort::from(port); if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { let wire_key = (src_node, src_port.index()); @@ -190,7 +229,36 @@ impl HugrEngine { wire_key, value ); - value + if value.is_some() { + return value; + } + // FLATTENING: classical values cross DFG boundaries + // structurally, exactly like qubit tracing does. A DFG node's + // outputs live at its Output child's sources; a DFG's Input + // node ports live at the DFG node's own input wires. Bounded + // by nesting depth (belt-and-braces cap above: a malformed + // graph must not recurse unboundedly). + match hugr.get_optype(src_node) { + OpType::DFG(_) => { + let output_node = hugr.get_io(src_node).map(|[_, o]| o)?; + return self.get_input_value_depth( + hugr, + output_node, + src_port.index(), + depth + 1, + ); + } + OpType::Input(_) + if hugr + .get_parent(src_node) + .is_some_and(|p| matches!(hugr.get_optype(p), OpType::DFG(_))) => + { + let dfg_node = hugr.get_parent(src_node)?; + return self.get_input_value_depth(hugr, dfg_node, src_port.index(), depth + 1); + } + _ => {} + } + None } else { debug!("get_input_value({node:?}, {port}): no linked output"); None @@ -200,36 +268,75 @@ impl HugrEngine { /// Get a qubit ID from an input port. /// /// Follows the wire connected to the specified input port and returns - /// the qubit ID at the source, if any. + /// the qubit ID at the source, if any. Traces across flattened-DFG + /// boundaries the same way [`Self::get_input_value`] does, so both + /// value kinds resolve identically. pub(crate) fn get_input_qubit(&self, hugr: &Hugr, node: Node, port: usize) -> Option { + self.get_input_qubit_depth(hugr, node, port, 0) + } + + fn get_input_qubit_depth( + &self, + hugr: &Hugr, + node: Node, + port: usize, + depth: usize, + ) -> Option { + if depth > Self::MAX_DFG_TRACE_DEPTH { + debug!("get_input_qubit({node:?}, {port}): DFG trace depth exceeded"); + return None; + } let in_port = IncomingPort::from(port); if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { let wire_key = (src_node, src_port.index()); - self.wire_state.wire_to_qubit.get(&wire_key).copied() + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&wire_key) { + return Some(qubit_id); + } + match hugr.get_optype(src_node) { + OpType::DFG(_) => { + let output_node = hugr.get_io(src_node).map(|[_, o]| o)?; + self.get_input_qubit_depth(hugr, output_node, src_port.index(), depth + 1) + } + OpType::Input(_) + if hugr + .get_parent(src_node) + .is_some_and(|p| matches!(hugr.get_optype(p), OpType::DFG(_))) => + { + let dfg_node = hugr.get_parent(src_node)?; + self.get_input_qubit_depth(hugr, dfg_node, src_port.index(), depth + 1) + } + _ => None, + } } else { None } } - /// Propagate qubit array from input to output (for pass-through operations). + /// Propagate a pass-through operation's input to its output port + /// (barriers, `StateResult`). /// - /// This handles operations like barriers that pass qubit arrays through unchanged. - pub(crate) fn propagate_qubit_array(&mut self, hugr: &Hugr, node: Node) { - // For now, just propagate qubit wire mappings - let in_port = IncomingPort::from(0); - if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { - let src_key = (src_node, src_port.index()); - - // Propagate qubit array if present - if let Some(qubits) = self.wire_state.qubit_arrays.get(&src_key).cloned() { - self.wire_state.qubit_arrays.insert((node, 0), qubits); - } - - // Also propagate individual qubit mappings - if let Some(qubit_id) = self.wire_state.wire_to_qubit.get(&src_key).copied() { - self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); + /// Qubit arrays travel as `ClassicalValue::Array` of `QubitRef`s, so + /// the CLASSICAL value is what must pass through; a bare qubit wire + /// passes its mapping. Both read through the tracing layer so an input + /// produced across a flattened-DFG boundary resolves. Returns false + /// when the input carried NEITHER: the caller must DEFER -- marking the + /// pass-through processed would permanently drop the late-arriving + /// value (these ops have no retry of their own). + #[must_use] + pub(crate) fn propagate_qubit_array(&mut self, hugr: &Hugr, node: Node) -> bool { + let mut found = false; + if let Some(value) = self.get_input_value(hugr, node, 0) { + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state.wire_to_qubit.insert((node, 0), *qubit_id); } + self.wire_state.classical_values.insert((node, 0), value); + found = true; + } + if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { + self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); + found = true; } + found } /// Resolve qubit IDs for an operation by following input wires. @@ -242,13 +349,13 @@ impl HugrEngine { hugr: &Hugr, node: Node, op: &QuantumOp, - ) -> Vec { + ) -> Option> { if op.gate_type == GateType::QAlloc { // QAlloc creates a new qubit let qubit_id = QubitId::from(self.wire_state.next_qubit_id); self.wire_state.next_qubit_id += 1; self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - return vec![qubit_id]; + return Some(vec![qubit_id]); } let mut qubits = Vec::with_capacity(op.num_qubit_inputs); @@ -260,8 +367,14 @@ impl HugrEngine { let mut wire_key = (src_node, src_port.index()); let src_op = hugr.get_optype(src_node); - // Check if the source is an Input node - if so, trace through it - if matches!(src_op, OpType::Input(_)) { + // Check if the source is an Input node - if so, trace through + // it -- UNLESS the Input port itself already carries a qubit + // mapping (e.g. a case Input holding a Sum-payload QubitRef): + // tracing past it would land on an outer wire with no + // mapping and misresolve the gate. + if matches!(src_op, OpType::Input(_)) + && !self.wire_state.wire_to_qubit.contains_key(&wire_key) + { debug!( "Input node detected: {:?}:{}, attempting trace", src_node, @@ -286,6 +399,26 @@ impl HugrEngine { } } + // A DFG source executes by FLATTENING: its output qubits + // live at its Output child's sources, never at the DFG + // node's own wires -- and the DFG node is marked processed + // at dispatch, so falling through would hit the implicit- + // allocation branch below and run this gate on a PHANTOM + // qubit. Trace instead; defer if the interior has not + // produced the qubit yet. + if matches!(src_op, OpType::DFG(_)) + && !self.wire_state.wire_to_qubit.contains_key(&wire_key) + { + if let Some(qubit_id) = self.get_input_qubit(hugr, node, port_idx) { + self.wire_state.wire_to_qubit.insert(wire_key, qubit_id); + } else { + debug!( + "resolve_qubits at {node:?}: DFG source {wire_key:?} not resolved, deferring" + ); + return None; + } + } + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&wire_key) { qubits.push(qubit_id); @@ -295,32 +428,42 @@ impl HugrEngine { .wire_to_qubit .insert((node, port_idx), qubit_id); } + } else if !self.processed.contains(&wire_key.0) + && !matches!(hugr.get_optype(wire_key.0), OpType::Input(_)) + { + // No mapping and the producer has NOT run yet (e.g. a + // borrow op or case payload still pending): defer the + // gate -- fabricating here applies it to a phantom qubit + // (observed as gates and measurements landing on + // disjoint qubit sets). The producer's completion + // re-queues this node. + debug!("resolve_qubits at {node:?}: producer {wire_key:?} pending, deferring"); + return None; } else { - // Fallback: create a new qubit ID + // The source already ran (or is a structural Input wire) + // and no mapping will ever appear: this is an implicit + // top-level qubit. Allocate it ONCE, keyed to the source + // wire so every consumer of this wire sees the same id. let fallback = QubitId::from(self.wire_state.next_qubit_id); self.wire_state.next_qubit_id += 1; + self.wire_state.wire_to_qubit.insert(wire_key, fallback); qubits.push(fallback); if port_idx < op.num_qubit_outputs { self.wire_state .wire_to_qubit .insert((node, port_idx), fallback); } - debug!( - "Warning: No wire mapping for {wire_key:?}, using fallback {fallback:?}" - ); + debug!("resolve_qubits: implicit qubit {fallback:?} for wire {wire_key:?}"); } } else { - // No linked output - create fallback - let fallback = QubitId::from(self.wire_state.next_qubit_id); - self.wire_state.next_qubit_id += 1; - qubits.push(fallback); debug!( - "Warning: No linked output for node {node:?} port {port_idx}, using fallback {fallback:?}" + "resolve_qubits at {node:?}: no linked output for port {port_idx}, deferring" ); + return None; } } - qubits + Some(qubits) } /// Try to load a constant value from a `LoadConstant` node. @@ -329,12 +472,9 @@ impl HugrEngine { /// extracts the value from the Const node and returns it as a `ClassicalValue`. /// /// Supports integer constants (`ConstInt`), float constants (`ConstF64`), - /// and boolean constants (`ConstBool`). + /// boolean constants (`ConstBool`), and tuple constants of those (e.g. + /// guppy's `pi` angle constant lowers to `Const(Tuple(FloatVal))`). pub(crate) fn try_load_constant(hugr: &Hugr, node: Node) -> Option { - use tket::extension::bool::ConstBool; - use tket::hugr::std_extensions::arithmetic::float_types::ConstF64; - use tket::hugr::std_extensions::arithmetic::int_types::ConstInt; - // LoadConstant has a static edge from a Const node for pred_node in hugr.input_neighbours(node) { let pred_op = hugr.get_optype(pred_node); @@ -346,30 +486,105 @@ impl HugrEngine { value.get_type() ); - // Try to extract as ConstInt - if let Some(const_int) = value.get_custom_value::() { - // ConstInt can be signed or unsigned - let int_value = const_int.value_s(); - debug!("try_load_constant: found ConstInt with value {int_value}"); - return Some(ClassicalValue::Int(int_value)); + if let Some(classical) = Self::const_value_to_classical(value) { + return Some(classical); } - // Try to extract as ConstF64 - if let Some(const_f64) = value.get_custom_value::() { - let float_value = const_f64.value(); - debug!("try_load_constant: found ConstF64 with value {float_value}"); - return Some(ClassicalValue::Float(float_value)); - } + debug!("try_load_constant: unrecognized const type"); + } + } - // Try to extract as ConstBool - if let Some(const_bool) = value.get_custom_value::() { - let bool_value = const_bool.value(); - debug!("try_load_constant: found ConstBool with value {bool_value}"); - return Some(ClassicalValue::Bool(bool_value)); - } + None + } - debug!("try_load_constant: unrecognized const type"); + /// Convert a HUGR constant `Value` to a runtime `ClassicalValue`. + /// + /// Tuple constants convert element-wise so a downstream `UnpackTuple` + /// can unpack them at runtime exactly like a `MakeTuple`-built tuple. + fn const_value_to_classical(value: &Value) -> Option { + use tket::extension::bool::ConstBool; + use tket::extension::rotation::ConstRotation; + use tket::hugr::std_extensions::arithmetic::float_types::ConstF64; + use tket::hugr::std_extensions::arithmetic::int_types::ConstInt; + + if let Some(const_rot) = value.get_custom_value::() { + let half_turns = const_rot.half_turns(); + debug!("const_value_to_classical: found ConstRotation with {half_turns} half-turns"); + return Some(ClassicalValue::Rotation(half_turns)); + } + + // ConstInt can be signed or unsigned + if let Some(const_int) = value.get_custom_value::() { + let int_value = const_int.value_s(); + debug!("const_value_to_classical: found ConstInt with value {int_value}"); + return Some(ClassicalValue::Int(int_value)); + } + + if let Some(const_usize) = + value.get_custom_value::() + { + let usize_value = const_usize.value(); + debug!("const_value_to_classical: found ConstUsize with value {usize_value}"); + return Some(ClassicalValue::UInt(usize_value)); + } + + if value + .get_custom_value::() + .is_some() + { + // Error constants feed prelude.panic (which faults before + // reading its input) -- an opaque token keeps the LoadConstant + // from deferring forever and stalling instead of panicking. + debug!("const_value_to_classical: found ConstError"); + return Some(ClassicalValue::Tuple(vec![])); + } + + if let Some(const_f64) = value.get_custom_value::() { + let float_value = const_f64.value(); + debug!("const_value_to_classical: found ConstF64 with value {float_value}"); + return Some(ClassicalValue::Float(float_value)); + } + + if let Some(const_bool) = value.get_custom_value::() { + let bool_value = const_bool.value(); + debug!("const_value_to_classical: found ConstBool with value {bool_value}"); + return Some(ClassicalValue::Bool(bool_value)); + } + + // Sum constants: a two-variant sum with no payload is a plain HUGR + // bool (False=0/True=1); a single-variant tag-0 sum is a tuple; any + // other variant shape is a general tagged sum. + if let Value::Sum(sum) = value { + let num_variants = sum.sum_type.num_variants(); + if num_variants == 2 && sum.values.is_empty() { + debug!( + "const_value_to_classical: found unit-sum bool with tag {}", + sum.tag + ); + return Some(ClassicalValue::Bool(sum.tag == 1)); } + let elements: Option> = sum + .values + .iter() + .map(Self::const_value_to_classical) + .collect(); + let elements = elements?; + if num_variants == 1 && sum.tag == 0 { + debug!( + "const_value_to_classical: found tuple const with {} elements", + elements.len() + ); + return Some(ClassicalValue::Tuple(elements)); + } + debug!( + "const_value_to_classical: found sum const with tag {} and {} elements", + sum.tag, + elements.len() + ); + return Some(ClassicalValue::Sum { + tag: sum.tag, + values: elements, + }); } None diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index 38add32bd..3dc400c9f 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -44,16 +44,20 @@ use std::collections::BTreeMap; /// State for tracking wire values through the HUGR graph. /// /// This groups all wire-related state: qubit mappings, classical values, -/// and qubit arrays. The propagation system uses this to track values -/// as they flow from output ports to connected input ports. +/// and conditional Sum payloads. The propagation system uses this to track +/// values as they flow from output ports to connected input ports. #[derive(Debug, Default, Clone)] pub struct WireState { /// Map from (node, `output_port`) to qubit ID for tracking wire flow. pub wire_to_qubit: BTreeMap, /// Classical wire values: tracks bool/integer/float values flowing through wires. pub classical_values: BTreeMap, - /// Maps array wire keys to lists of qubit IDs for qubit arrays. - pub qubit_arrays: BTreeMap>, + /// Payload elements of a Conditional output resolved through the + /// structural Tag fallback, keyed by (conditional node, output port). + /// A DEDICATED map: storing these at "virtual" classical-value ports + /// aliased the Conditional's real output ports (payload for output 0 + /// landed on output 1's wire key). + pub conditional_payloads: BTreeMap>, /// Next available qubit ID. pub next_qubit_id: usize, } @@ -63,7 +67,7 @@ impl WireState { pub fn reset(&mut self) { self.wire_to_qubit.clear(); self.classical_values.clear(); - self.qubit_arrays.clear(); + self.conditional_payloads.clear(); self.next_qubit_id = 0; } } @@ -187,6 +191,23 @@ pub enum ClassicalOpType { Imul, Idiv, Imod, + /// Combined Euclidean division+remainder: two outputs (q, r). + Idivmod, + /// Checked combined div+mod: `sum_with_error(tuple(q, r))`. + IdivmodChecked, + /// Exponentiation; the exponent is treated as unsigned per the spec. + Ipow, + /// `LoadFunction`: produce a `FuncRef` value for the static target. + LoadFunc, + /// Convert a 1-bit integer to bool. + ItoBool, + /// Convert a bool to a 1-bit integer. + IfromBool, + /// Checked division: `sum_with_error(int)` -- error variant (tag 0) on + /// division by zero, value variant (tag 1) otherwise + IdivChecked, + /// Checked modulo: `sum_with_error(int)` like [`Self::IdivChecked`] + ImodChecked, Ineg, Iabs, // Integer comparisons @@ -229,6 +250,11 @@ pub enum ClassicalOpType { // Tuple operations MakeTuple, UnpackTuple, + // Sum construction (HUGR `Tag` nodes: variants, options, branch selectors) + TagSum, + /// Checked float->int truncation: `sum_with_error(int)` -- error variant + /// (tag 0) for NaN/infinite/out-of-range inputs + ConvertFloatToIntChecked, } /// Classical operation extracted from HUGR. @@ -280,16 +306,34 @@ pub enum ClassicalValue { Float(f64), /// Tuple of values Tuple(Vec), + /// Tagged sum value (HUGR variants: options, loop continue/break, + /// branch selectors). A tuple is the 1-variant special case and a + /// bool the 2-variant empty-payload special case; this carries the + /// general form. + Sum { + /// The variant tag. + tag: usize, + /// The payload values of the active variant. + values: Vec, + }, /// Array of values Array(Vec), /// Future handle (for lazy measurements) Future(FutureId), /// Rotation angle (in half-turns, i.e., multiples of pi) Rotation(f64), + /// A first-class function value: the `FuncDefn` it references and the + /// type args instantiating it (produced by `LoadFunction`, consumed by + /// higher-order ops like scan -- generic scanned functions resolve + /// type-level naturals through these, symmetric with Call frames). + FuncRef(Node, Vec), /// RNG context handle RngContext(RngContextId), /// Qubit reference (for storing qubits in arrays) QubitRef(QubitId), + /// Borrowed-out slot in a borrow array (a hole left by + /// `collections.borrow_arr.borrow`; filled by `return`) + Borrowed, } impl ClassicalValue { @@ -300,13 +344,17 @@ impl ClassicalValue { Self::Bool(b) => Some(u32::from(*b)), Self::Int(i) => u32::try_from(*i).ok(), Self::UInt(u) => u32::try_from(*u).ok(), + // A sum's control-flow decision value is its variant tag. + Self::Sum { tag, .. } => u32::try_from(*tag).ok(), Self::Float(_) | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::Rotation(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::FuncRef(..) + | Self::Borrowed => None, } } @@ -320,12 +368,17 @@ impl ClassicalValue { Self::Int(i) => Some(*i != 0), Self::UInt(u) => Some(*u != 0), Self::Float(f) => Some(*f != 0.0), - Self::Tuple(_) + // HUGR bools are 2-variant sums with empty payloads. + Self::Sum { tag, values } if values.is_empty() => Some(*tag != 0), + Self::Sum { .. } + | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::Rotation(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::FuncRef(..) + | Self::Borrowed => None, } } @@ -340,12 +393,15 @@ impl ClassicalValue { Self::Int(i) => Some(*i), Self::UInt(u) => i64::try_from(*u).ok(), Self::Float(f) => Some(*f as i64), - Self::Tuple(_) + Self::Sum { .. } + | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::Rotation(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::FuncRef(..) + | Self::Borrowed => None, } } @@ -360,12 +416,15 @@ impl ClassicalValue { Self::Int(i) => u64::try_from(*i).ok(), Self::UInt(u) => Some(*u), Self::Float(f) => Some(*f as u64), - Self::Tuple(_) + Self::Sum { .. } + | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::Rotation(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::FuncRef(..) + | Self::Borrowed => None, } } @@ -381,11 +440,14 @@ impl ClassicalValue { Self::UInt(u) => Some(*u as f64), Self::Float(f) => Some(*f), Self::Rotation(r) => Some(*r), // Rotation can be interpreted as float (half-turns) - Self::Tuple(_) + Self::Sum { .. } + | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::FuncRef(..) + | Self::Borrowed => None, } } @@ -499,9 +561,17 @@ pub enum FutureState { qubit: QubitId, /// Index in `measurement_mappings` for result retrieval. measurement_index: usize, + /// True for `Future` (`LazyMeasureLeaked`: 0/1/2-leaked); + /// false for `Future`. Read must produce the declared type. + int_valued: bool, + }, + /// The measurement result is available (same `int_valued` semantics). + Resolved { + /// The measurement outcome. + outcome: u32, + /// See [`FutureState::Pending::int_valued`]. + int_valued: bool, }, - /// The measurement result is available. - Resolved(u32), } // --- Container Type Classification --- @@ -580,6 +650,61 @@ impl RngContextState { let result = bits as f64 / (1u64 << 53) as f64; result } + + /// Advance the generator by `steps` in O(log steps), exactly as if + /// `next_u64` had been called `steps` times. + /// + /// xorshift64 is a LINEAR map over GF(2): one step is multiplication + /// by a fixed 64x64 bit matrix, so stepping k times is the matrix + /// power M^k. Backtracking uses the generator's period 2^64 - 1 + /// (all nonzero states form one cycle): k steps back == period - k + /// steps forward. + pub fn jump(&mut self, steps: u128) { + const PERIOD: u128 = (1u128 << 64) - 1; + let steps = steps % PERIOD; + if steps == 0 { + return; + } + // Row i of the one-step matrix = transform applied to basis 1<> 7; + x ^= x << 17; + x + }); + let mat_vec = |m: &[u64; 64], v: u64| -> u64 { + let mut out = 0u64; + for (i, row) in m.iter().enumerate() { + if (v >> i) & 1 == 1 { + out ^= row; + } + } + out + }; + let mat_mul = |a: &[u64; 64], b: &[u64; 64]| -> [u64; 64] { + std::array::from_fn(|i| mat_vec(a, b[i])) + }; + // result = M^steps via binary exponentiation. + let identity: [u64; 64] = std::array::from_fn(|i| 1u64 << i); + let mut result = identity; + let mut base = step_matrix; + let mut e = steps; + while e > 0 { + if e & 1 == 1 { + result = mat_mul(&result, &base); + } + base = mat_mul(&base, &base); + e >>= 1; + } + self.state = mat_vec(&result, self.state); + } + + /// Step `steps` BACKWARD, exactly undoing that many `next_u64` calls. + pub fn jump_back(&mut self, steps: u64) { + const PERIOD: u128 = (1u128 << 64) - 1; + self.jump(PERIOD - (u128::from(steps) % PERIOD)); + } } // --- Conditional Control Flow Types --- @@ -665,6 +790,10 @@ pub struct DataflowBlockInfo { pub extension_ops: BTreeSet, /// All `TailLoop` nodes inside this block. pub tailloop_nodes: BTreeSet, + /// `LoadConstant` nodes inside this block: they execute like every + /// other op, so block emptiness and completion must count them (a + /// constants-only block used to transition before they ran). + pub load_constants: BTreeSet, /// Input node inside this block (kept for future wire tracing). #[allow(dead_code)] pub input_node: Option, @@ -683,6 +812,10 @@ pub struct ActiveCfgInfo { pub current_block: Node, /// Blocks that have been fully processed. pub completed_blocks: BTreeSet, + /// Number of block transitions taken (loop-iteration ceiling: guppy + /// loops lower to CFG cycles, so a never-breaking classical loop would + /// otherwise spin forever). + pub transitions: u64, } // --- Function Call Types --- @@ -716,10 +849,40 @@ pub struct ActiveCallInfo { pub call_node: Node, /// The `FuncDefn` being called. pub func_defn_node: Node, + /// The Call's instantiation type arguments, used to resolve type + /// variables inside the called body (e.g. `prelude.load_nat` of a + /// generic bounded-nat parameter such as a loop bound). + pub type_args: Vec, } // --- TailLoop Control Flow Types --- +/// State of an in-flight higher-order array `scan`: the engine runs the +/// scanned function once per element through the normal Call-frame +/// machinery, so quantum ops inside the function (e.g. `measure_array`'s +/// per-qubit measure) go through real measurement rounds. +#[derive(Debug, Clone)] +pub struct ActiveScanInfo { + /// The scan node itself. + pub scan_node: Node, + /// The scanned function. + pub func_defn_node: Node, + /// Elements not yet folded (front = next). + pub remaining: std::collections::VecDeque, + /// Mapped outputs collected so far. + pub results: Vec, + /// Current accumulator values (scan signature `*A`). + pub accs: Vec, + /// Type args instantiating the scanned function (from its + /// `LoadFunction`), for resolving type-level variables in the body. + pub type_args: Vec, + /// For a scanned function with a plain DATAFLOW body (no CFG): the + /// body ops whose completion finishes one element. Empty for + /// CFG-bodied functions (their frame completes through + /// `complete_func_call_if_needed`). + pub frame_ops: std::collections::BTreeSet, +} + /// Information about a `TailLoop` node. /// /// `TailLoop` executes its body repeatedly until the body outputs `BREAK_TAG` (1). @@ -758,6 +921,10 @@ pub struct TailLoopInfo { pub bool_ops: BTreeSet, /// All Conditional nodes inside this `TailLoop` body. pub conditional_nodes: BTreeSet, + /// Nested `TailLoop` nodes directly inside this `TailLoop` body. + pub tailloop_nodes: BTreeSet, + /// Nested CFG nodes directly inside this `TailLoop` body. + pub cfg_nodes: BTreeSet, /// Total number of `TailLoop` input ports. pub num_inputs: usize, /// Total number of `TailLoop` output ports. diff --git a/crates/pecos-hugr/src/engine/work_queue.rs b/crates/pecos-hugr/src/engine/work_queue.rs new file mode 100644 index 000000000..eacb5dabd --- /dev/null +++ b/crates/pecos-hugr/src/engine/work_queue.rs @@ -0,0 +1,116 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The engine's work queue: FIFO with a membership mirror. +//! +//! The queue is consulted with `contains` on every activation and retry +//! wave (dozens of sites); a raw `VecDeque` made each of those an O(n) +//! scan, quadratic-shaped across a retry storm. The membership set makes +//! them O(log n) and gives dedup on push for free: a node is never queued +//! twice, matching the `if !contains { push }` guards every call site +//! already carried. + +use std::collections::{BTreeSet, VecDeque}; + +use tket::hugr::Node; + +/// FIFO node queue with O(log n) membership and dedup on push. +#[derive(Debug, Default, Clone)] +pub(crate) struct WorkQueue { + queue: VecDeque, + members: BTreeSet, +} + +impl WorkQueue { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Enqueue at the back; a node already queued stays where it is. + pub(crate) fn push_back(&mut self, node: Node) { + if self.members.insert(node) { + self.queue.push_back(node); + } + } + + /// Enqueue at the front (priority); a node already queued stays where + /// it is. + pub(crate) fn push_front(&mut self, node: Node) { + if self.members.insert(node) { + self.queue.push_front(node); + } + } + + pub(crate) fn pop_front(&mut self) -> Option { + let node = self.queue.pop_front(); + if let Some(node) = node { + self.members.remove(&node); + } + node + } + + pub(crate) fn contains(&self, node: Node) -> bool { + self.members.contains(&node) + } + + pub(crate) fn len(&self) -> usize { + self.queue.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + pub(crate) fn clear(&mut self) { + self.queue.clear(); + self.members.clear(); + } +} + +impl<'a> IntoIterator for &'a WorkQueue { + type Item = &'a Node; + type IntoIter = std::collections::vec_deque::Iter<'a, Node>; + + fn into_iter(self) -> Self::IntoIter { + self.queue.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tket::hugr::HugrView; + + #[test] + fn dedup_and_order() { + // Real node ids from a trivial hugr (Node is not directly + // constructible). + let hugr = tket::hugr::Hugr::default(); + let nodes: Vec = hugr.nodes().take(1).collect(); + let n1 = nodes[0]; + + let mut q = WorkQueue::new(); + q.push_back(n1); + q.push_back(n1); // dedup: stays queued once + assert_eq!(q.len(), 1); + assert!(q.contains(n1)); + assert_eq!(q.pop_front(), Some(n1)); + assert!(!q.contains(n1)); + assert!(q.is_empty()); + q.push_front(n1); + assert_eq!(q.len(), 1); + q.clear(); + assert!(q.is_empty()); + } +} diff --git a/crates/pecos-llvm/Cargo.toml b/crates/pecos-llvm/Cargo.toml index 2e4cf0752..70aca136c 100644 --- a/crates/pecos-llvm/Cargo.toml +++ b/crates/pecos-llvm/Cargo.toml @@ -17,7 +17,6 @@ pecos-core.workspace = true # Inkwell for LLVM IR generation [dependencies.inkwell] workspace = true -features = ["llvm14-0"] [features] default = [] diff --git a/crates/pecos-llvm/README.md b/crates/pecos-llvm/README.md index b67c55385..e6a5fb748 100644 --- a/crates/pecos-llvm/README.md +++ b/crates/pecos-llvm/README.md @@ -16,12 +16,12 @@ Provides Rust types for generating LLVM IR, designed to be compatible with Pytho ## Relationship to pecos-build -- **pecos-build**: Manages LLVM 14 *installation* (downloading, finding) -- **pecos-llvm**: *Uses* LLVM 14 (via inkwell) for IR generation +- **pecos-build**: Manages LLVM 21.1 *installation* (downloading, finding) +- **pecos-llvm**: *Uses* LLVM 21.1 (via inkwell) for IR generation ## Requirements -Requires LLVM 14. Install with: +Requires LLVM 21.1. Install with: ```bash cargo run -p pecos -- install llvm ``` diff --git a/crates/pecos-llvm/build.rs b/crates/pecos-llvm/build.rs index 10908bd87..40337fad9 100644 --- a/crates/pecos-llvm/build.rs +++ b/crates/pecos-llvm/build.rs @@ -5,44 +5,48 @@ fn main() { } fn validate_llvm() { - use pecos_build::llvm::is_valid_llvm_14; + use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, is_valid_llvm}; use std::env; use std::path::PathBuf; - // Check if LLVM_SYS_140_PREFIX is already set and valid - if let Ok(sys_prefix) = env::var("LLVM_SYS_140_PREFIX") { + // Check if LLVM_SYS_PREFIX_ENV is already set and valid + if let Ok(sys_prefix) = env::var(LLVM_SYS_PREFIX_ENV) { let path = PathBuf::from(&sys_prefix); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { // LLVM is configured and valid, we're good! return; } eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("ERROR: Invalid LLVM_SYS_140_PREFIX"); + eprintln!("ERROR: Invalid {LLVM_SYS_PREFIX_ENV}"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("LLVM_SYS_140_PREFIX is set to: {sys_prefix}"); - eprintln!("But this is not a valid LLVM 14 installation."); + eprintln!("{LLVM_SYS_PREFIX_ENV} is set to: {sys_prefix}"); + eprintln!("But this is not a valid LLVM {REQUIRED_VERSION} installation."); eprintln!(); eprintln!("Please either:"); - eprintln!(" 1. Fix the path to point to a valid LLVM 14 installation"); + eprintln!(" 1. Fix the path to point to a valid LLVM {REQUIRED_VERSION} installation"); eprintln!(" 2. Unset it and configure LLVM:"); - eprintln!(" unset LLVM_SYS_140_PREFIX"); + eprintln!(" unset {LLVM_SYS_PREFIX_ENV}"); eprintln!(" pecos llvm configure"); eprintln!("═══════════════════════════════════════════════════════════════\n"); - panic!("Invalid LLVM_SYS_140_PREFIX. See error message above."); + panic!("Invalid {LLVM_SYS_PREFIX_ENV}. See error message above."); } - // LLVM_SYS_140_PREFIX not set - print setup instructions + // LLVM_SYS_PREFIX_ENV not set - print setup instructions print_llvm_not_found_error_extended(); - panic!("LLVM 14 not configured. See error message above for setup instructions."); + panic!( + "LLVM {REQUIRED_VERSION} not configured. See error message above for setup instructions." + ); } fn print_llvm_not_found_error_extended() { + use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION}; + eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("LLVM 14 Setup Required for pecos-qir"); + eprintln!("LLVM {REQUIRED_VERSION} Setup Required for pecos-qir"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("The pecos-qir crate requires LLVM 14 for QIR generation."); + eprintln!("The pecos-qir crate requires LLVM {REQUIRED_VERSION} for QIR generation."); eprintln!("Choose one of these installation methods:"); eprintln!(); eprintln!("Option 1: Use pecos setup (recommended)"); @@ -50,14 +54,16 @@ fn print_llvm_not_found_error_extended() { eprintln!(" cargo build"); eprintln!(); eprintln!(" This detects and installs all missing dependencies."); - eprintln!(" (LLVM 14: ~400 MB download, installs to ~/.pecos/deps/llvm-14/)"); + eprintln!( + " (LLVM {REQUIRED_VERSION}: several hundred MB download, installs to ~/.pecos/deps/llvm-{REQUIRED_VERSION}/)" + ); eprintln!(); #[cfg(target_os = "macos")] { eprintln!("Option 2: Install via Homebrew"); - eprintln!(" # Install LLVM 14"); - eprintln!(" brew install llvm@14"); + eprintln!(" # Install LLVM 21"); + eprintln!(" brew install llvm@21"); eprintln!(); eprintln!(" # Configure PECOS to use it"); eprintln!(" pecos llvm configure"); @@ -75,14 +81,14 @@ fn print_llvm_not_found_error_extended() { eprintln!(); eprintln!(" Debian/Ubuntu:"); eprintln!(" sudo apt update"); - eprintln!(" sudo apt install llvm-14 llvm-14-dev"); + eprintln!(" sudo apt install llvm-21 llvm-21-dev"); eprintln!(); eprintln!(" Fedora/RHEL:"); - eprintln!(" sudo dnf install llvm14 llvm14-devel"); + eprintln!(" sudo dnf install llvm21 llvm21-devel"); eprintln!(); eprintln!(" Arch Linux:"); - eprintln!(" # LLVM 14 may need to be built from AUR"); - eprintln!(" yay -S llvm14"); + eprintln!(" # LLVM 21 may need to come from an alternate repository"); + eprintln!(" yay -S llvm21"); eprintln!(); eprintln!(" Then configure and build:"); eprintln!(" pecos llvm configure"); @@ -102,7 +108,7 @@ fn print_llvm_not_found_error_extended() { eprintln!(" https://github.com/vovkos/llvm-package-windows"); eprintln!(); eprintln!(" After extracting to C:\\LLVM (or similar):"); - eprintln!(" set LLVM_SYS_140_PREFIX=C:\\LLVM"); + eprintln!(" set {LLVM_SYS_PREFIX_ENV}=C:\\LLVM"); eprintln!(" pecos llvm configure"); eprintln!(" cargo build"); eprintln!(); @@ -112,9 +118,9 @@ fn print_llvm_not_found_error_extended() { eprintln!(" Instead of 'configure', you can set environment variables:"); eprintln!(); #[cfg(target_os = "windows")] - eprintln!(" set LLVM_SYS_140_PREFIX=C:\\path\\to\\llvm"); + eprintln!(" set {LLVM_SYS_PREFIX_ENV}=C:\\path\\to\\llvm"); #[cfg(not(target_os = "windows"))] - eprintln!(" export LLVM_SYS_140_PREFIX=/path/to/llvm"); + eprintln!(" export {LLVM_SYS_PREFIX_ENV}=/path/to/llvm"); #[cfg(not(target_os = "windows"))] eprintln!(" Or add llvm-config to PATH:"); #[cfg(not(target_os = "windows"))] diff --git a/crates/pecos-llvm/src/lib.rs b/crates/pecos-llvm/src/lib.rs index 8919bcb8b..4563acf94 100644 --- a/crates/pecos-llvm/src/lib.rs +++ b/crates/pecos-llvm/src/lib.rs @@ -25,6 +25,6 @@ pub mod prelude; // Re-export main types at crate root for convenience pub use llvm_compat::{ - LLConstant, LLContext, LLFunction, LLFunctionType, LLIRBuilder, LLModule, LLResult, LLType, - LLValue, + LLConstant, LLContext, LLFunction, LLFunctionType, LLIRBuilder, LLModule, LLPointerValue, + LLResult, LLType, LLValue, gep_result_pointee_type, }; diff --git a/crates/pecos-llvm/src/llvm_compat.rs b/crates/pecos-llvm/src/llvm_compat.rs index 90ad38f80..4780dd5eb 100644 --- a/crates/pecos-llvm/src/llvm_compat.rs +++ b/crates/pecos-llvm/src/llvm_compat.rs @@ -32,14 +32,13 @@ use inkwell::basic_block::BasicBlock; use inkwell::builder::Builder; use inkwell::context::Context; use inkwell::module::Module; -use inkwell::types::{ - ArrayType, BasicType, BasicTypeEnum, FloatType, IntType, PointerType, StructType, -}; +use inkwell::types::{ArrayType, BasicTypeEnum, FloatType, IntType, PointerType, StructType}; use inkwell::values::{ ArrayValue, BasicValueEnum, FloatValue, FunctionValue, GlobalValue, IntValue, PointerValue, }; use inkwell::{AddressSpace, IntPredicate}; use pecos_core::prelude::PecosError; +use std::num::NonZeroU32; pub type LLResult = Result; @@ -117,7 +116,9 @@ impl<'ctx> LLModule<'ctx> { /// Get the LLVM bitcode as bytes pub fn to_bitcode(&self) -> Vec { - self.module.write_bitcode_to_memory().as_slice().to_vec() + let buffer = self.module.write_bitcode_to_memory(); + let bitcode = buffer.as_slice(); + bitcode[..bitcode.len().saturating_sub(1)].to_vec() } /// Get an identified (opaque) type by name, creating it if it doesn't exist @@ -137,7 +138,9 @@ impl<'ctx> LLModule<'ctx> { LLType::Array(t) => self.module.add_global(t, None, name), LLType::Int(t) => self.module.add_global(t, None, name), LLType::Float(t) => self.module.add_global(t, None, name), - LLType::Pointer(t) => self.module.add_global(t, None, name), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => { + self.module.add_global(t, None, name) + } LLType::Struct(t) => self.module.add_global(t, None, name), LLType::Void => panic!("Cannot create global variable of void type"), }; @@ -146,7 +149,7 @@ impl<'ctx> LLModule<'ctx> { match init_val { LLValue::Int(v) => global.set_initializer(&v), LLValue::Float(v) => global.set_initializer(&v), - LLValue::Pointer(v) => global.set_initializer(&v), + LLValue::Pointer(v) => global.set_initializer(&v.value()), LLValue::Array(v) => global.set_initializer(&v), } } @@ -156,8 +159,17 @@ impl<'ctx> LLModule<'ctx> { /// Add a function declaration (mirrors llvmlite's ir.Function) pub fn add_function(&mut self, name: &str, fn_type: LLFunctionType<'ctx>) -> LLFunction<'ctx> { - let function = self.module.add_function(name, fn_type.get(), None); - LLFunction { function } + let LLFunctionType { + fn_type, + ret_pointee_type, + param_pointee_types, + } = fn_type; + let function = self.module.add_function(name, fn_type, None); + LLFunction { + function, + ret_pointee_type, + param_pointee_types, + } } } @@ -166,9 +178,11 @@ impl<'ctx> LLModule<'ctx> { // ============================================================================ /// Wrapper for LLVM function types (mirrors llvmlite's ir.FunctionType) -#[derive(Copy, Clone)] +#[derive(Clone)] pub struct LLFunctionType<'ctx> { fn_type: inkwell::types::FunctionType<'ctx>, + ret_pointee_type: Option>, + param_pointee_types: Vec>>, } impl<'ctx> LLFunctionType<'ctx> { @@ -187,12 +201,19 @@ impl<'ctx> LLFunctionType<'ctx> { } LLType::Int(t) => t.fn_type(¶ms, var_args), LLType::Float(t) => t.fn_type(¶ms, var_args), - LLType::Pointer(t) => t.fn_type(¶ms, var_args), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => t.fn_type(¶ms, var_args), LLType::Struct(t) => t.fn_type(¶ms, var_args), LLType::Array(t) => t.fn_type(¶ms, var_args), }; - Self { fn_type } + Self { + fn_type, + ret_pointee_type: return_type.pointer_pointee_type(), + param_pointee_types: param_types + .iter() + .map(LLType::pointer_pointee_type) + .collect(), + } } #[must_use] @@ -211,24 +232,56 @@ impl<'ctx> LLFunctionType<'ctx> { LLType::Void => context.void_type().fn_type(¶ms, var_args), LLType::Int(t) => t.fn_type(¶ms, var_args), LLType::Float(t) => t.fn_type(¶ms, var_args), - LLType::Pointer(t) => t.fn_type(¶ms, var_args), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => t.fn_type(¶ms, var_args), LLType::Struct(t) => t.fn_type(¶ms, var_args), LLType::Array(t) => t.fn_type(¶ms, var_args), }; - Self { fn_type } + Self { + fn_type, + ret_pointee_type: return_type.pointer_pointee_type(), + param_pointee_types: param_types + .iter() + .map(LLType::pointer_pointee_type) + .collect(), + } } #[must_use] pub fn get(&self) -> inkwell::types::FunctionType<'ctx> { self.fn_type } + + #[must_use] + pub fn ret_pointee_type(&self) -> Option> { + self.ret_pointee_type + } + + #[must_use] + pub fn param_pointee_types(&self) -> &[Option>] { + &self.param_pointee_types + } } -/// Wrapper for LLVM types that mirrors llvmlite's type hierarchy +/// Wrapper for LLVM types that mirrors llvmlite's type hierarchy. +/// +/// LLVM 21 uses opaque pointer types. `TypedPointer` carries one level of +/// pointee metadata for APIs like load and GEP that still need the element +/// type; nested pointers keep only the immediate pointer pointee. #[derive(Clone, Copy, PartialEq, Eq)] pub enum LLType<'ctx> { Void, + Int(IntType<'ctx>), + Float(FloatType<'ctx>), + Pointer(PointerType<'ctx>), + TypedPointer(PointerType<'ctx>, LLPointeeType<'ctx>), + Struct(StructType<'ctx>), + Array(ArrayType<'ctx>), +} + +/// One-level pointee metadata for opaque LLVM pointer types. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LLPointeeType<'ctx> { Int(IntType<'ctx>), Float(FloatType<'ctx>), Pointer(PointerType<'ctx>), @@ -236,6 +289,90 @@ pub enum LLType<'ctx> { Array(ArrayType<'ctx>), } +impl<'ctx> LLPointeeType<'ctx> { + fn from_ll_type(ty: LLType<'ctx>) -> Option { + match ty { + LLType::Void => None, + LLType::Int(t) => Some(Self::Int(t)), + LLType::Float(t) => Some(Self::Float(t)), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => Some(Self::Pointer(t)), + LLType::Struct(t) => Some(Self::Struct(t)), + LLType::Array(t) => Some(Self::Array(t)), + } + } + + fn to_ll_type(self) -> LLType<'ctx> { + match self { + Self::Int(t) => LLType::Int(t), + Self::Float(t) => LLType::Float(t), + Self::Pointer(t) => LLType::Pointer(t), + Self::Struct(t) => LLType::Struct(t), + Self::Array(t) => LLType::Array(t), + } + } +} + +impl std::hash::Hash for LLPointeeType<'_> { + fn hash(&self, state: &mut H) { + use inkwell::types::AsTypeRef; + match self { + LLPointeeType::Int(t) => { + 1u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + LLPointeeType::Float(t) => { + 2u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + LLPointeeType::Pointer(t) => { + 3u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + LLPointeeType::Struct(t) => { + 4u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + LLPointeeType::Array(t) => { + 5u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + } + } +} + +fn basic_type_to_ll_type(ty: BasicTypeEnum<'_>) -> Option> { + match ty { + BasicTypeEnum::ArrayType(t) => Some(LLType::Array(t)), + BasicTypeEnum::FloatType(t) => Some(LLType::Float(t)), + BasicTypeEnum::IntType(t) => Some(LLType::Int(t)), + BasicTypeEnum::PointerType(t) => Some(LLType::Pointer(t)), + BasicTypeEnum::StructType(t) => Some(LLType::Struct(t)), + BasicTypeEnum::ScalableVectorType(_) | BasicTypeEnum::VectorType(_) => None, + } +} + +#[must_use] +pub fn gep_result_pointee_type<'ctx>( + pointee_type: LLType<'ctx>, + indices: &[IntValue<'ctx>], +) -> Option> { + let mut current = pointee_type; + for index in indices.iter().skip(1) { + current = match current { + LLType::Array(t) => basic_type_to_ll_type(t.get_element_type())?, + LLType::Struct(t) => { + let field_index = u32::try_from(index.get_zero_extended_constant()?).ok()?; + basic_type_to_ll_type(t.get_field_type_at_index(field_index)?)? + } + LLType::Int(_) | LLType::Float(_) | LLType::Pointer(_) | LLType::TypedPointer(_, _) => { + current + } + LLType::Void => return None, + }; + } + Some(current) +} + // inkwell 0.8.0 only derives `Hash` for `IntType`; the other type wrappers // are `Eq` (LLVM type-ref pointer equality) but not `Hash`. Hash the same // `LLVMTypeRef` pointer so `Hash` stays consistent with that `Eq`. @@ -256,6 +393,11 @@ impl std::hash::Hash for LLType<'_> { 3u8.hash(state); (t.as_type_ref() as usize).hash(state); } + LLType::TypedPointer(t, pointee_type) => { + 3u8.hash(state); + (t.as_type_ref() as usize).hash(state); + pointee_type.hash(state); + } LLType::Struct(t) => { 4u8.hash(state); (t.as_type_ref() as usize).hash(state); @@ -282,13 +424,21 @@ impl<'ctx> LLType<'ctx> { match bits { // Use custom_width_int_type(1) instead of bool_type() to match llvmlite // llvmlite renders i1 constants as "i1 1" and "i1 0", not "i1 true" and "i1 false" - 1 => LLType::Int(context.custom_width_int_type(1)), + 1 => LLType::Int( + context + .custom_width_int_type(NonZeroU32::new(1).expect("nonzero width")) + .expect("i1 is a valid LLVM integer width"), + ), 8 => LLType::Int(context.i8_type()), 16 => LLType::Int(context.i16_type()), 32 => LLType::Int(context.i32_type()), 64 => LLType::Int(context.i64_type()), 128 => LLType::Int(context.i128_type()), - _ => LLType::Int(context.custom_width_int_type(bits)), + _ => LLType::Int( + context + .custom_width_int_type(NonZeroU32::new(bits).expect("nonzero width")) + .expect("valid LLVM integer width"), + ), } } @@ -304,7 +454,7 @@ impl<'ctx> LLType<'ctx> { match element_type { LLType::Int(t) => LLType::Array(t.array_type(count)), LLType::Float(t) => LLType::Array(t.array_type(count)), - LLType::Pointer(t) => LLType::Array(t.array_type(count)), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => LLType::Array(t.array_type(count)), LLType::Struct(t) => LLType::Array(t.array_type(count)), LLType::Array(t) => LLType::Array(t.array_type(count)), LLType::Void => panic!("Cannot create array of void type"), @@ -315,18 +465,41 @@ impl<'ctx> LLType<'ctx> { #[must_use] pub fn as_pointer(&self, context: &'ctx Context) -> LLType<'ctx> { match self { - LLType::Void => { - // Void pointers are represented as i8* - LLType::Pointer(context.i8_type().ptr_type(AddressSpace::default())) + LLType::Void => LLType::Pointer(context.ptr_type(AddressSpace::default())), + LLType::Int(_) | LLType::Float(_) | LLType::Struct(_) | LLType::Array(_) => { + let ptr_type = context.ptr_type(AddressSpace::default()); + LLType::TypedPointer( + ptr_type, + LLPointeeType::from_ll_type(*self).expect("non-void pointee"), + ) + } + LLType::Pointer(t) | LLType::TypedPointer(t, _) => { + LLType::TypedPointer(*t, LLPointeeType::Pointer(*t)) } - LLType::Int(t) => LLType::Pointer(t.ptr_type(AddressSpace::default())), - LLType::Float(t) => LLType::Pointer(t.ptr_type(AddressSpace::default())), - LLType::Pointer(t) => LLType::Pointer(*t), // Already a pointer - LLType::Struct(t) => LLType::Pointer(t.ptr_type(AddressSpace::default())), - LLType::Array(t) => LLType::Pointer(t.ptr_type(AddressSpace::default())), } } + /// Return tracked pointee metadata for typed opaque pointer types. + #[must_use] + pub fn pointer_pointee_type(&self) -> Option> { + match self { + LLType::TypedPointer(_, pointee_type) => Some(pointee_type.to_ll_type()), + _ => None, + } + } + + /// Build an opaque pointer type with explicit one-level pointee metadata. + #[must_use] + pub fn typed_pointer( + pointer_type: PointerType<'ctx>, + pointee_type: LLType<'ctx>, + ) -> Option { + Some(LLType::TypedPointer( + pointer_type, + LLPointeeType::from_ll_type(pointee_type)?, + )) + } + /// Get the underlying inkwell type for function signatures #[must_use] pub fn to_basic_metadata_type(&self) -> Option> { @@ -334,7 +507,7 @@ impl<'ctx> LLType<'ctx> { LLType::Void => None, LLType::Int(t) => Some((*t).into()), LLType::Float(t) => Some((*t).into()), - LLType::Pointer(t) => Some((*t).into()), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => Some((*t).into()), LLType::Struct(t) => Some((*t).into()), LLType::Array(t) => Some((*t).into()), } @@ -353,7 +526,7 @@ impl<'ctx> LLType<'ctx> { #[must_use] pub fn as_pointer_type(&self) -> PointerType<'ctx> { match self { - LLType::Pointer(t) => *t, + LLType::Pointer(t) | LLType::TypedPointer(t, _) => *t, _ => panic!("Expected pointer type"), } } @@ -372,12 +545,39 @@ impl<'ctx> LLType<'ctx> { // Value wrappers // ============================================================================ +/// Pointer value plus the pointee type needed by LLVM opaque-pointer APIs. +#[derive(Clone, Copy)] +pub struct LLPointerValue<'ctx> { + value: PointerValue<'ctx>, + pointee_type: Option>, +} + +impl<'ctx> LLPointerValue<'ctx> { + #[must_use] + pub fn new(value: PointerValue<'ctx>, pointee_type: Option>) -> Self { + Self { + value, + pointee_type, + } + } + + #[must_use] + pub fn value(self) -> PointerValue<'ctx> { + self.value + } + + #[must_use] + pub fn pointee_type(self) -> Option> { + self.pointee_type + } +} + /// Wrapper for LLVM values that mirrors llvmlite's value types #[derive(Clone, Copy)] pub enum LLValue<'ctx> { Int(IntValue<'ctx>), Float(FloatValue<'ctx>), - Pointer(PointerValue<'ctx>), + Pointer(LLPointerValue<'ctx>), Array(ArrayValue<'ctx>), } @@ -387,7 +587,7 @@ impl<'ctx> LLValue<'ctx> { match self { LLValue::Int(v) => (*v).into(), LLValue::Float(v) => (*v).into(), - LLValue::Pointer(v) => (*v).into(), + LLValue::Pointer(v) => v.value().into(), LLValue::Array(v) => (*v).into(), } } @@ -411,7 +611,15 @@ impl<'ctx> LLValue<'ctx> { #[must_use] pub fn as_pointer_value(&self) -> PointerValue<'ctx> { match self { - LLValue::Pointer(v) => *v, + LLValue::Pointer(v) => v.value(), + _ => panic!("Expected pointer value"), + } + } + + #[must_use] + pub fn pointer_pointee_type(&self) -> Option> { + match self { + LLValue::Pointer(v) => v.pointee_type(), _ => panic!("Expected pointer value"), } } @@ -430,8 +638,11 @@ impl<'ctx> LLValue<'ctx> { // ============================================================================ /// Wrapper around inkwell's `FunctionValue` that mirrors llvmlite's ir.Function +#[derive(Clone)] pub struct LLFunction<'ctx> { function: FunctionValue<'ctx>, + ret_pointee_type: Option>, + param_pointee_types: Vec>>, } impl<'ctx> LLFunction<'ctx> { @@ -450,14 +661,19 @@ impl<'ctx> LLFunction<'ctx> { LLType::Void => module.context().void_type().fn_type(¶m_types, false), LLType::Int(t) => t.fn_type(¶m_types, false), LLType::Float(t) => t.fn_type(¶m_types, false), - LLType::Pointer(t) => t.fn_type(¶m_types, false), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => t.fn_type(¶m_types, false), LLType::Struct(t) => t.fn_type(¶m_types, false), LLType::Array(t) => t.fn_type(¶m_types, false), }; let function = module.get_mut().add_function(name, fn_type, None); + let ret_pointee_type = return_type.pointer_pointee_type(); - Self { function } + Self { + function, + ret_pointee_type, + param_pointee_types: arg_types.iter().map(LLType::pointer_pointee_type).collect(), + } } #[must_use] @@ -465,6 +681,34 @@ impl<'ctx> LLFunction<'ctx> { self.function } + #[must_use] + pub fn ret_pointee_type(&self) -> Option> { + self.ret_pointee_type + } + + #[must_use] + pub fn param_pointee_type(&self, index: usize) -> Option> { + self.param_pointee_types.get(index).copied().flatten() + } + + /// Return function parameters with tracked pointer metadata where known. + #[must_use] + pub fn args(&self) -> Vec> { + self.function + .get_param_iter() + .enumerate() + .map(|(index, param)| match param { + BasicValueEnum::IntValue(v) => LLValue::Int(v), + BasicValueEnum::FloatValue(v) => LLValue::Float(v), + BasicValueEnum::PointerValue(v) => { + LLValue::Pointer(LLPointerValue::new(v, self.param_pointee_type(index))) + } + BasicValueEnum::ArrayValue(v) => LLValue::Array(v), + _ => panic!("Unsupported parameter type"), + }) + .collect() + } + /// Append a basic block to this function (mirrors llvmlite's `func.append_basic_block`) #[must_use] pub fn append_basic_block(&self, context: &'ctx Context, name: &str) -> BasicBlock<'ctx> { @@ -476,6 +720,38 @@ impl<'ctx> LLFunction<'ctx> { // IRBuilder wrapper // ============================================================================ +pub struct LLCallable<'ctx> { + function: FunctionValue<'ctx>, + ret_pointee_type: Option>, +} + +impl<'ctx> From> for LLCallable<'ctx> { + fn from(function: FunctionValue<'ctx>) -> Self { + Self { + function, + ret_pointee_type: None, + } + } +} + +impl<'ctx> From> for LLCallable<'ctx> { + fn from(function: LLFunction<'ctx>) -> Self { + Self { + function: function.get(), + ret_pointee_type: function.ret_pointee_type(), + } + } +} + +impl<'ctx> From<&LLFunction<'ctx>> for LLCallable<'ctx> { + fn from(function: &LLFunction<'ctx>) -> Self { + Self { + function: function.get(), + ret_pointee_type: function.ret_pointee_type(), + } + } +} + /// Wrapper around inkwell's Builder that mirrors llvmlite's ir.IRBuilder pub struct LLIRBuilder<'ctx> { builder: Builder<'ctx>, @@ -671,22 +947,28 @@ impl<'ctx> LLIRBuilder<'ctx> { // Function calls // ======================================================================== - pub fn call( + pub fn call( &self, - function: FunctionValue<'ctx>, + function: F, args: &[LLValue<'ctx>], name: &str, - ) -> LLResult>> { + ) -> LLResult>> + where + F: Into>, + { + let function = function.into(); let arg_values: Vec<_> = args.iter().map(|v| v.to_basic_value().into()).collect(); let call_site = self .builder - .build_call(function, &arg_values, name) + .build_call(function.function, &arg_values, name) .map_err(|e| PecosError::Generic(format!("Failed to build call: {e}")))?; Ok(call_site.try_as_basic_value().basic().map(|v| match v { BasicValueEnum::IntValue(i) => LLValue::Int(i), - BasicValueEnum::PointerValue(p) => LLValue::Pointer(p), + BasicValueEnum::PointerValue(p) => { + LLValue::Pointer(LLPointerValue::new(p, function.ret_pointee_type)) + } _ => panic!("Unsupported return value type"), })) } @@ -740,13 +1022,28 @@ impl<'ctx> LLIRBuilder<'ctx> { name: &str, ) -> LLResult> { let idx_values: Vec<_> = indices.iter().map(LLValue::as_int_value).collect(); + let pointee_type = ptr + .pointer_pointee_type() + .ok_or_else(|| PecosError::Generic("gep: pointer pointee type is unknown".into()))?; + let basic_pointee_type = pointee_type + .to_basic_metadata_type() + .ok_or_else(|| PecosError::Generic("gep: pointer to void is not supported".into()))?; + let result_pointee_type = gep_result_pointee_type(pointee_type, &idx_values); unsafe { let result = self .builder - .build_gep(ptr.as_pointer_value(), &idx_values, name) + .build_gep( + basic_pointee_type, + ptr.as_pointer_value(), + &idx_values, + name, + ) .map_err(|e| PecosError::Generic(format!("Failed to build gep: {e}")))?; - Ok(LLValue::Pointer(result)) + Ok(LLValue::Pointer(LLPointerValue::new( + result, + result_pointee_type, + ))) } } @@ -764,19 +1061,25 @@ impl<'ctx> LLIRBuilder<'ctx> { .builder .build_alloca(basic_ty, name) .map_err(|e| PecosError::Generic(format!("Failed to build alloca: {e}")))?; - Ok(LLValue::Pointer(result)) + Ok(LLValue::Pointer(LLPointerValue::new(result, Some(ll_type)))) } - /// `load` (LLVM-14 typed pointer: pointee inferred from `ptr`). + /// `load` (LLVM opaque-pointer API requires an explicit pointee type). pub fn load(&self, ptr: LLValue<'ctx>, name: &str) -> LLResult> { + let pointee_type = ptr + .pointer_pointee_type() + .ok_or_else(|| PecosError::Generic("load: pointer pointee type is unknown".into()))?; + let basic_pointee_type = pointee_type + .to_basic_metadata_type() + .ok_or_else(|| PecosError::Generic("load: pointer to void is not supported".into()))?; let result = self .builder - .build_load(ptr.as_pointer_value(), name) + .build_load(basic_pointee_type, ptr.as_pointer_value(), name) .map_err(|e| PecosError::Generic(format!("Failed to build load: {e}")))?; Ok(match result { BasicValueEnum::IntValue(v) => LLValue::Int(v), BasicValueEnum::FloatValue(v) => LLValue::Float(v), - BasicValueEnum::PointerValue(v) => LLValue::Pointer(v), + BasicValueEnum::PointerValue(v) => LLValue::Pointer(LLPointerValue::new(v, None)), BasicValueEnum::ArrayValue(v) => LLValue::Array(v), other => { return Err(PecosError::Generic(format!( @@ -905,7 +1208,9 @@ impl LLConstant { match ll_type { LLType::Int(t) => Ok(LLValue::Int(t.const_zero())), LLType::Float(t) => Ok(LLValue::Float(t.const_zero())), - LLType::Pointer(t) => Ok(LLValue::Pointer(t.const_zero())), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => Ok(LLValue::Pointer( + LLPointerValue::new(t.const_zero(), ll_type.pointer_pointee_type()), + )), LLType::Array(t) => Ok(LLValue::Array(t.const_zero())), LLType::Void | LLType::Struct(_) => Err(PecosError::Generic( "Cannot create a zero constant for void/struct type".to_string(), @@ -913,3 +1218,108 @@ impl LLConstant { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn call_preserves_pointer_return_pointee_type_for_load() { + let context = Context::create(); + let mut module = LLModule::new(&context, "call_pointer_return"); + let i32_type = LLType::int(&context, 32); + let i32_ptr_type = i32_type.as_pointer(&context); + + let callee_type = LLFunctionType::new(i32_ptr_type, &[], false); + let callee = module.add_function("get_i32_ptr", callee_type); + + let caller_type = LLFunctionType::new_with_context(&context, LLType::Void, &[], false); + let caller = module.add_function("caller", caller_type); + let block = caller.append_basic_block(&context, "entry"); + let builder = LLIRBuilder::new(&context, block); + + let ptr = builder + .call(&callee, &[], "ptr") + .expect("call should build") + .expect("callee returns a pointer"); + let loaded = builder + .load(ptr, "loaded") + .expect("call result should keep pointee metadata"); + assert!(matches!(loaded, LLValue::Int(_))); + builder.ret_void().expect("return should build"); + } + + #[test] + fn raw_function_value_call_has_unknown_pointer_pointee_type() { + let context = Context::create(); + let mut module = LLModule::new(&context, "raw_call_pointer_return"); + let i32_type = LLType::int(&context, 32); + let i32_ptr_type = i32_type.as_pointer(&context); + + let callee_type = LLFunctionType::new(i32_ptr_type, &[], false); + let callee = module.add_function("get_i32_ptr", callee_type); + + let caller_type = LLFunctionType::new_with_context(&context, LLType::Void, &[], false); + let caller = module.add_function("caller", caller_type); + let block = caller.append_basic_block(&context, "entry"); + let builder = LLIRBuilder::new(&context, block); + + let ptr = builder + .call(callee.get(), &[], "ptr") + .expect("call should build") + .expect("callee returns a pointer"); + let Err(err) = builder.load(ptr, "loaded") else { + panic!("raw FunctionValue should not carry pointee metadata"); + }; + assert!(err.to_string().contains("pointer pointee type is unknown")); + builder.ret_void().expect("return should build"); + } + + #[test] + fn as_pointer_on_typed_pointer_models_pointer_to_pointer() { + let context = Context::create(); + let mut module = LLModule::new(&context, "double_pointer_return"); + let i32_type = LLType::int(&context, 32); + let i32_ptr_type = i32_type.as_pointer(&context); + let i32_ptr_ptr_type = i32_ptr_type.as_pointer(&context); + + let callee_type = LLFunctionType::new(i32_ptr_ptr_type, &[], false); + let callee = module.add_function("get_i32_ptr_ptr", callee_type); + + let caller_type = LLFunctionType::new_with_context(&context, LLType::Void, &[], false); + let caller = module.add_function("caller", caller_type); + let block = caller.append_basic_block(&context, "entry"); + let builder = LLIRBuilder::new(&context, block); + + let ptr_to_ptr = builder + .call(&callee, &[], "ptr_to_ptr") + .expect("call should build") + .expect("callee returns a pointer"); + let loaded_ptr = builder + .load(ptr_to_ptr, "loaded_ptr") + .expect("double pointer load should load a pointer"); + assert!(matches!(loaded_ptr, LLValue::Pointer(_))); + builder.ret_void().expect("return should build"); + } + + #[test] + fn function_args_preserve_pointer_pointee_type_for_load() { + let context = Context::create(); + let mut module = LLModule::new(&context, "pointer_arg"); + let i32_type = LLType::int(&context, 32); + let i32_ptr_type = i32_type.as_pointer(&context); + + let function_type = + LLFunctionType::new_with_context(&context, LLType::Void, &[i32_ptr_type], false); + let function = module.add_function("takes_i32_ptr", function_type); + let block = function.append_basic_block(&context, "entry"); + let builder = LLIRBuilder::new(&context, block); + + let args = function.args(); + let loaded = builder + .load(args[0], "loaded") + .expect("pointer arg should keep pointee metadata"); + assert!(matches!(loaded, LLValue::Int(_))); + builder.ret_void().expect("return should build"); + } +} diff --git a/crates/pecos-llvm/src/prelude.rs b/crates/pecos-llvm/src/prelude.rs index d87f93232..06c7224ab 100644 --- a/crates/pecos-llvm/src/prelude.rs +++ b/crates/pecos-llvm/src/prelude.rs @@ -17,6 +17,6 @@ //! This module re-exports the main public API for LLVM IR generation. pub use crate::llvm_compat::{ - LLConstant, LLContext, LLFunction, LLFunctionType, LLIRBuilder, LLModule, LLResult, LLType, - LLValue, + LLConstant, LLContext, LLFunction, LLFunctionType, LLIRBuilder, LLModule, LLPointerValue, + LLResult, LLType, LLValue, gep_result_pointee_type, }; diff --git a/crates/pecos-mwpf/src/core_traits.rs b/crates/pecos-mwpf/src/core_traits.rs index fe7922ccb..fface82e1 100644 --- a/crates/pecos-mwpf/src/core_traits.rs +++ b/crates/pecos-mwpf/src/core_traits.rs @@ -19,13 +19,16 @@ use crate::decoder::MwpfDecoder; /// This is the primary trait used by the fast decode path /// (`SampleBatch.decode_count`, `sample_decode_count`, etc.). impl pecos_decoder_core::ObservableDecoder for MwpfDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> std::result::Result { + ) -> std::result::Result + { let result = self .decode_syndrome(syndrome) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; - Ok(result.observable_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + result.observable_mask, + )) } } diff --git a/crates/pecos-mwpf/src/decoder.rs b/crates/pecos-mwpf/src/decoder.rs index 244fa6edf..c3e52343a 100644 --- a/crates/pecos-mwpf/src/decoder.rs +++ b/crates/pecos-mwpf/src/decoder.rs @@ -182,6 +182,10 @@ impl MwpfDecoder { pub fn from_dem(dem_str: &str, config: MwpfConfig) -> Result { let dem = DemCheckMatrix::from_dem_str(dem_str) .map_err(|e| MwpfError::InvalidDem(e.to_string()))?; + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in the `1 << o` loop below. + dem.ensure_observables_fit_u64() + .map_err(|e| MwpfError::InvalidDem(e.to_string()))?; // Build hyperedges from the check matrix. Each mechanism (column) becomes // one HyperEdge with all its incident detectors. diff --git a/crates/pecos-num/src/lib.rs b/crates/pecos-num/src/lib.rs index 3976769be..538ef4e85 100644 --- a/crates/pecos-num/src/lib.rs +++ b/crates/pecos-num/src/lib.rs @@ -45,6 +45,7 @@ pub mod optimize; pub mod polynomial; pub mod prelude; pub mod random; +pub mod special; pub mod stats; pub mod z2_linalg; @@ -56,4 +57,5 @@ pub use graph::Graph; pub use linalg::{matrix_exp, matrix_log}; pub use optimize::{BrentqOptions, NewtonOptions, OptimizeError, brentq, newton}; pub use polynomial::{Poly1d, PolynomialError, polyfit}; -pub use stats::mean; +pub use special::{betainc_inv, betainc_reg, ln_gamma}; +pub use stats::{jeffreys_interval, mean}; diff --git a/crates/pecos-num/src/prelude.rs b/crates/pecos-num/src/prelude.rs index b8e0044bb..2dafd93d5 100644 --- a/crates/pecos-num/src/prelude.rs +++ b/crates/pecos-num/src/prelude.rs @@ -31,10 +31,13 @@ pub use crate::random; // Re-export statistical functions pub use crate::stats::{ - jackknife_resamples, jackknife_stats, jackknife_stats_axis, jackknife_weighted, mean, - mean_axis, std, std_axis, weighted_mean, + jackknife_resamples, jackknife_stats, jackknife_stats_axis, jackknife_weighted, + jeffreys_interval, mean, mean_axis, std, std_axis, weighted_mean, }; +// Re-export special functions +pub use crate::special::{betainc_inv, betainc_reg, ln_gamma}; + // Re-export mathematical traits (use these for polymorphism!) pub use crate::math::{ Abs, Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceil, Cos, Cosh, Exp, Floor, Ln, LogBase, diff --git a/crates/pecos-num/src/special.rs b/crates/pecos-num/src/special.rs new file mode 100644 index 000000000..214657bbf --- /dev/null +++ b/crates/pecos-num/src/special.rs @@ -0,0 +1,468 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Special functions: log-gamma and the regularized incomplete beta +//! function with its inverse. +//! +//! These are the primitives behind Beta-distribution quantiles, which in +//! turn back the Jeffreys binomial interval in [`crate::stats`]. +//! +//! Algorithms follow Numerical Recipes, 3rd edition, section 6.1 +//! (Lanczos log-gamma) and section 6.4 (Lentz continued fraction for the +//! incomplete beta; Halley iteration with the Abramowitz & Stegun 26.5.22 +//! initial guess for the inverse). Differential tests against `SciPy` +//! reference values live in the test module below. + +/// Convergence threshold for the inverse incomplete beta Halley iteration. +const BETAINC_INV_EPS: f64 = 1.0e-8; +/// Maximum iterations for the incomplete-beta continued fraction. +const BETACF_MAX_ITER: u32 = 10_000; +/// Convergence threshold for the continued fraction. +const BETACF_EPS: f64 = 3.0e-14; +/// Smallest representable ratio guard for Lentz's method. +const BETACF_FPMIN: f64 = f64::MIN_POSITIVE / BETACF_EPS; + +/// Natural logarithm of the gamma function for `x > 0`. +/// +/// Drop-in replacement for `scipy.special.gammaln` on the positive real +/// axis. Uses the 14-term Lanczos approximation (Numerical Recipes 3rd +/// ed., section 6.1), accurate to roughly machine precision. +/// +/// Returns NaN for `x <= 0`. +/// +/// # Examples +/// +/// ``` +/// use pecos_num::special::ln_gamma; +/// +/// // Gamma(1) = 1, Gamma(2) = 1 +/// assert!(ln_gamma(1.0).abs() < 1e-14); +/// assert!(ln_gamma(2.0).abs() < 1e-14); +/// // Gamma(0.5) = sqrt(pi) +/// let half = std::f64::consts::PI.sqrt().ln(); +/// assert!((ln_gamma(0.5) - half).abs() < 1e-14); +/// ``` +#[must_use] +pub fn ln_gamma(x: f64) -> f64 { + const COF: [f64; 14] = [ + 57.156_235_665_862_92, + -59.597_960_355_475_49, + 14.136_097_974_741_746, + -0.491_913_816_097_620_2, + 3.399_464_998_481_189e-5, + 4.652_362_892_704_858e-5, + -9.837_447_530_487_956e-5, + 1.580_887_032_249_125e-4, + -2.102_644_417_241_049e-4, + 2.174_396_181_152_126e-4, + -1.643_181_065_367_639e-4, + 8.441_822_398_385_274e-5, + -2.619_083_840_158_141e-5, + 3.689_918_265_953_162e-6, + ]; + const LANCZOS_G: f64 = 5.242_187_5; + const SQRT_2PI: f64 = 2.506_628_274_631_000_5; + + if x <= 0.0 { + return f64::NAN; + } + + let mut denom = x; + let tmp = x + LANCZOS_G; + let tmp = (x + 0.5) * tmp.ln() - tmp; + let mut series = 0.999_999_999_999_997_1; + for c in COF { + denom += 1.0; + series += c / denom; + } + tmp + (SQRT_2PI * series / x).ln() +} + +/// Continued-fraction evaluation for the incomplete beta function +/// (Numerical Recipes 3rd ed., section 6.4, via Lentz's method). +fn betacf(a: f64, b: f64, x: f64) -> f64 { + let qab = a + b; + let qap = a + 1.0; + let qam = a - 1.0; + let mut c = 1.0; + let mut d = 1.0 - qab * x / qap; + if d.abs() < BETACF_FPMIN { + d = BETACF_FPMIN; + } + d = 1.0 / d; + let mut h = d; + + for m in 1..=BETACF_MAX_ITER { + let m = f64::from(m); + let m2 = 2.0 * m; + + // Even step of the continued fraction. + let aa = m * (b - m) * x / ((qam + m2) * (a + m2)); + d = 1.0 + aa * d; + if d.abs() < BETACF_FPMIN { + d = BETACF_FPMIN; + } + c = 1.0 + aa / c; + if c.abs() < BETACF_FPMIN { + c = BETACF_FPMIN; + } + d = 1.0 / d; + h *= d * c; + + // Odd step. + let aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2)); + d = 1.0 + aa * d; + if d.abs() < BETACF_FPMIN { + d = BETACF_FPMIN; + } + c = 1.0 + aa / c; + if c.abs() < BETACF_FPMIN { + c = BETACF_FPMIN; + } + d = 1.0 / d; + let del = d * c; + h *= del; + + if (del - 1.0).abs() <= BETACF_EPS { + return h; + } + } + // The fraction converges for all valid inputs; reaching the iteration + // cap means the inputs were extreme enough that the result is unusable. + f64::NAN +} + +/// Regularized incomplete beta function `I_x(a, b)` for `a, b > 0` and +/// `x` in `[0, 1]`. +/// +/// Drop-in replacement for `scipy.special.betainc`. This is also the CDF +/// of the Beta(a, b) distribution evaluated at `x`. +/// +/// Returns NaN outside the valid domain. +/// +/// # Examples +/// +/// ``` +/// use pecos_num::special::betainc_reg; +/// +/// // Symmetric case: I_{0.5}(a, a) = 0.5 +/// assert!((betainc_reg(10.0, 10.0, 0.5) - 0.5).abs() < 1e-12); +/// // Boundaries +/// assert_eq!(betainc_reg(2.0, 3.0, 0.0), 0.0); +/// assert_eq!(betainc_reg(2.0, 3.0, 1.0), 1.0); +/// ``` +#[must_use] +pub fn betainc_reg(a: f64, b: f64, x: f64) -> f64 { + let domain_ok = a > 0.0 && b > 0.0 && (0.0..=1.0).contains(&x); + if !domain_ok { + return f64::NAN; + } + if x <= 0.0 { + return 0.0; + } + if x >= 1.0 { + return 1.0; + } + + // Prefactor x^a (1-x)^b / (a B(a,b)), computed in log space. + let ln_front = ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b) + a * x.ln() + b * (1.0 - x).ln(); + let front = ln_front.exp(); + + // Use the continued fraction directly where it converges fastest, + // and the symmetry I_x(a,b) = 1 - I_{1-x}(b,a) otherwise. + if x < (a + 1.0) / (a + b + 2.0) { + front * betacf(a, b, x) / a + } else { + 1.0 - front * betacf(b, a, 1.0 - x) / b + } +} + +/// Inverse of the regularized incomplete beta function: returns `x` such +/// that `betainc_reg(a, b, x) == p`. +/// +/// Matches `scipy.special.betaincinv` (this is the quantile / inverse CDF +/// of the Beta(a, b) distribution) over the parameter scales validated by +/// the test module: shape parameters up to roughly binomial-trial counts +/// of 1e12 and `p` away from the extreme tails by more than ~1e-15. The +/// upper tail is computed through the symmetry +/// `betainc_inv(a, b, p) = 1 - betainc_inv(b, a, 1 - p)` so both tails +/// share the well-conditioned lower-tail path; beyond those scales the +/// continued fraction can converge spuriously, so callers with extreme +/// parameters must validate independently. +/// +/// Follows Numerical Recipes 3rd ed., section 6.4: an initial guess from +/// Abramowitz & Stegun 26.5.22 refined by Halley iterations. +/// +/// Returns NaN outside the valid domain (`a, b > 0`, `p` in `[0, 1]`). +/// +/// # Examples +/// +/// ``` +/// use pecos_num::special::{betainc_inv, betainc_reg}; +/// +/// let x = betainc_inv(2.0, 3.0, 0.6); +/// assert!((betainc_reg(2.0, 3.0, x) - 0.6).abs() < 1e-10); +/// ``` +#[must_use] +pub fn betainc_inv(a: f64, b: f64, p: f64) -> f64 { + let domain_ok = a > 0.0 && b > 0.0 && (0.0..=1.0).contains(&p); + if !domain_ok { + return f64::NAN; + } + if p <= 0.0 { + return 0.0; + } + if p >= 1.0 { + return 1.0; + } + // Compute upper-tail quantiles through the lower tail of the mirrored + // distribution: `err = betainc_reg(...) - p` loses all precision when + // p is within ~1e-10 of 1 (the Halley correction then stalls on a + // cancelled residual), while 1 - p is exact in the mirrored call. + if p > 0.5 { + return 1.0 - betainc_inv(b, a, 1.0 - p); + } + + let a1 = a - 1.0; + let b1 = b - 1.0; + + let mut x: f64; + if a >= 1.0 && b >= 1.0 { + // Abramowitz & Stegun 26.5.22 via the normal quantile + // approximation 26.2.23. + let pp = if p < 0.5 { p } else { 1.0 - p }; + let t = (-2.0 * pp.ln()).sqrt(); + let mut gauss = (2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t; + if p < 0.5 { + gauss = -gauss; + } + let al = (gauss * gauss - 3.0) / 6.0; + let h = 2.0 / (1.0 / (2.0 * a - 1.0) + 1.0 / (2.0 * b - 1.0)); + let w = gauss * (al + h).sqrt() / h + - (1.0 / (2.0 * b - 1.0) - 1.0 / (2.0 * a - 1.0)) * (al + 5.0 / 6.0 - 2.0 / (3.0 * h)); + x = a / (a + b * (2.0 * w).exp()); + } else { + let lna = (a / (a + b)).ln(); + let lnb = (b / (a + b)).ln(); + let t = (a * lna).exp() / a; + let u = (b * lnb).exp() / b; + let w = t + u; + x = if p < t / w { + (a * w * p).powf(1.0 / a) + } else { + 1.0 - (b * w * (1.0 - p)).powf(1.0 / b) + }; + } + + let afac = ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b); + for iteration in 0..10 { + if x <= 0.0 { + return 0.0; + } + if x >= 1.0 { + return 1.0; + } + let err = betainc_reg(a, b, x) - p; + let t = (a1 * x.ln() + b1 * (1.0 - x).ln() + afac).exp(); + let u = err / t; + // Halley step. + let step = u / (1.0 - 0.5 * f64::min(1.0, u * (a1 / x - b1 / (1.0 - x)))); + x -= step; + if x <= 0.0 { + x = 0.5 * (x + step); + } + if x >= 1.0 { + x = 0.5 * (x + step + 1.0); + } + if step.abs() < BETAINC_INV_EPS * x && iteration > 0 { + break; + } + } + x +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Assert `actual` matches `expected` to a RELATIVE tolerance. + /// + /// A pure relative check: callers that legitimately expect a value of + /// exactly zero must handle that case separately (the `ln_gamma` test + /// does). An earlier version OR'd in an absolute `abs_err <= rel_tol` + /// fallback, which silently weakened the relative tolerance to an + /// absolute one for `|expected| < 1` (e.g. a claimed 1e-12 relative + /// became 1e-12 absolute — ~1000x looser at `expected = 1e-3`). + fn assert_close(actual: f64, expected: f64, rel_tol: f64) { + let denom = expected.abs().max(f64::MIN_POSITIVE); + let rel_err = (actual - expected).abs() / denom; + assert!( + rel_err <= rel_tol, + "expected {expected:.17e}, got {actual:.17e} (relative error {rel_err:.3e} > {rel_tol:.3e})" + ); + } + + // Reference values generated with: + // uv run python -c "from scipy import special; print(special.gammaln(x))" + // (scipy 1.x, double precision) + #[test] + fn ln_gamma_matches_scipy() { + let cases = [ + (0.5, 0.572_364_942_924_7), + (1.0, 0.0), + (1.5, -0.120_782_237_635_245_26), + (2.0, 0.0), + (3.7, 1.428_072_326_665_388), + (10.0, 12.801_827_480_081_469), + (100.5, 361.435_540_467_777_57), + (1000.0, 5_905.220_423_209_181), + ]; + for (x, expected) in cases { + let actual = ln_gamma(x); + if expected == 0.0 { + assert!(actual.abs() < 1e-13, "ln_gamma({x}) = {actual:.3e}, want 0"); + } else { + assert_close(actual, expected, 1e-12); + } + } + } + + #[test] + fn ln_gamma_invalid_domain_is_nan() { + assert!(ln_gamma(0.0).is_nan()); + assert!(ln_gamma(-1.5).is_nan()); + } + + // Reference values generated with: + // uv run python -c "from scipy import special; print(special.betainc(a, b, x))" + #[test] + fn betainc_reg_matches_scipy() { + let cases = [ + (0.5, 0.5, 0.3, 0.369_010_119_565_545_36), + (2.0, 3.0, 0.4, 0.524_799_999_999_999_9), + (5.5, 1.5, 0.7, 0.251_904_453_669_740_85), + (10.0, 10.0, 0.5, 0.5), + (0.5, 20.5, 0.01, 0.476_541_531_548_465_6), + (100.5, 900.5, 0.1, 0.494_385_987_853_672_66), + (3.5, 0.5, 0.99, 0.797_971_695_234_850_9), + ]; + for (a, b, x, expected) in cases { + assert_close(betainc_reg(a, b, x), expected, 1e-12); + } + } + + #[test] + fn betainc_reg_invalid_domain_is_nan() { + assert!(betainc_reg(0.0, 1.0, 0.5).is_nan()); + assert!(betainc_reg(1.0, -1.0, 0.5).is_nan()); + assert!(betainc_reg(1.0, 1.0, -0.1).is_nan()); + assert!(betainc_reg(1.0, 1.0, 1.1).is_nan()); + } + + // Reference values generated with: + // uv run python -c "from scipy import special; print(special.betaincinv(a, b, p))" + #[test] + fn betainc_inv_matches_scipy() { + let cases = [ + (0.5, 0.5, 0.25, 0.146_446_609_406_726_24), + (2.0, 3.0, 0.6, 0.444_500_002_083_767_4), + (5.5, 1.5, 0.05, 0.505_461_253_650_681_3), + (10.0, 10.0, 0.975, 0.711_356_752_083_001_1), + (0.5, 20.5, 0.995, 0.176_754_097_436_689_93), + (100.5, 900.5, 0.025, 0.082_562_652_843_060_04), + // Binomial-CI-scale parameters: n = 20000 trials, far tail. + (50.5, 19_950.5, 0.999_999, 0.004_581_655_467_494_118_5), + ]; + for (a, b, p, expected) in cases { + assert_close(betainc_inv(a, b, p), expected, 1e-8); + } + } + + // Reference values generated with: + // uv run python -c "from scipy import special; print(special.betaincinv(a, b, p))" + // Upper-tail quantiles exercise the symmetry path (the direct Halley + // iteration loses the residual to cancellation beyond p ~ 1 - 1e-10). + #[test] + fn betainc_inv_upper_tail_matches_scipy() { + let cases: [(f64, f64, f64, f64); 4] = [ + (2.0, 3.0, 0.999_999_9, 0.997_073_840_091_498_9), + (100.5, 900.5, 1.0 - 1e-12, 0.179_649_794_238_11), + (7.5, 19_993.5, 1.0 - 2.3e-16, 0.002_728_615_291_135_757_6), + (0.5, 0.5, 0.999_999, 0.999_999_999_997_532_6), + ]; + for (a, b, p, expected) in cases { + let actual = betainc_inv(a, b, p); + let scale: f64 = expected.abs(); + assert!( + ((actual - expected).abs() / scale) < 1e-8, + "betainc_inv({a}, {b}, {p}): expected {expected:.12e}, got {actual:.12e}" + ); + } + } + + #[test] + fn betainc_inv_tails_are_symmetric() { + // Relative comparison, with cases chosen so neither side hits + // f64 representation limits: p stays >= 1e-6 (forming `1 - p` + // closer to 1 destroys p's precision before the function is even + // called) and the quantiles stay far enough from 0 and 1 that + // `1 - upper` keeps its significant digits. Outside those limits + // a mirrored comparison measures representation error, not + // implementation error. + let cases: [(f64, f64, &[f64]); 3] = [ + (2.0, 3.0, &[1e-6, 0.01, 0.3]), + (50.5, 19_950.5, &[1e-6, 0.01, 0.3]), + // Quantiles for this shape at small p sit below 1e-13, where + // the mirrored side cannot represent them; compare only at + // moderate p. + (0.5, 20.5, &[0.01, 0.3]), + ]; + for (a, b, ps) in cases { + for &p in ps { + let lower = betainc_inv(a, b, p); + let upper = betainc_inv(b, a, 1.0 - p); + let mirrored = 1.0 - upper; + assert!( + ((lower - mirrored) / lower).abs() < 1e-8, + "tail symmetry failed for a={a}, b={b}, p={p}: {lower} vs {mirrored}" + ); + } + } + } + + #[test] + fn betainc_inv_round_trips_through_betainc_reg() { + for &(a, b) in &[(0.5, 0.5), (2.0, 3.0), (7.5, 19_993.5), (100.5, 900.5)] { + for &p in &[1e-6, 0.025, 0.5, 0.975, 1.0 - 1e-6] { + let x = betainc_inv(a, b, p); + let back = betainc_reg(a, b, x); + assert!( + (back - p).abs() < 1e-9, + "round trip failed for a={a}, b={b}, p={p}: x={x}, back={back}" + ); + } + } + } + + // Allow exact float comparisons: the edge cases return the sentinel + // values 0.0 and 1.0 verbatim. + #[allow(clippy::float_cmp)] + #[test] + fn betainc_inv_edges() { + assert_eq!(betainc_inv(2.0, 3.0, 0.0), 0.0); + assert_eq!(betainc_inv(2.0, 3.0, 1.0), 1.0); + assert!(betainc_inv(0.0, 3.0, 0.5).is_nan()); + assert!(betainc_inv(2.0, 3.0, -0.1).is_nan()); + } +} diff --git a/crates/pecos-num/src/stats.rs b/crates/pecos-num/src/stats.rs index 278b49108..533b691e3 100644 --- a/crates/pecos-num/src/stats.rs +++ b/crates/pecos-num/src/stats.rs @@ -33,11 +33,101 @@ //! - [`jackknife_weighted`] - Jackknife resampling for weighted/grouped data (full workflow) //! - [`weighted_mean`] - Calculate weighted mean from (value, weight) pairs //! +//! ## Binomial Proportions +//! - [`jeffreys_interval`] - Jeffreys credible interval for a binomial proportion +//! //! The slice functions are fast and simple for 1D data. The axis functions //! provide idiomatic Rust API for multi-dimensional arrays. +use crate::special::betainc_inv; use ndarray::{Array, ArrayView, Axis, Dimension, RemoveAxis}; +/// Jeffreys credible interval for a binomial proportion. +/// +/// Computes the equal-tailed interval of the Beta(k + 1/2, n - k + 1/2) +/// posterior arising from the Jeffreys prior Beta(1/2, 1/2), following +/// Brown, Cai & `DasGupta`, "Interval Estimation for a Binomial +/// Proportion", Statistical Science 16(2), 2001. Per that paper's +/// standard modification, the lower bound is 0 when `successes == 0` and +/// the upper bound is 1 when `successes == trials`. +/// +/// # Arguments +/// +/// * `successes` - Number of observed successes (k) +/// * `trials` - Number of trials (n), must be nonzero +/// * `confidence` - Interval mass, e.g. 0.95; must be in (0, 1) +/// +/// # Returns +/// +/// `(lower, upper)` bounds on the proportion. +/// +/// # Panics +/// +/// Panics if `trials == 0`, `trials > 10^12`, `successes > trials`, or +/// `confidence` is not in (0, 1). The first three are contract +/// violations; the trials cap marks the scale beyond which the +/// underlying incomplete-beta continued fraction has not been validated +/// (it can converge spuriously for extreme shape parameters, returning a +/// nonsense interval without warning). Also panics if the computed +/// bounds come back inverted — a numeric-breakdown trip-wire that should +/// be unreachable within the supported scales. +/// +/// # Examples +/// +/// ``` +/// use pecos_num::stats::jeffreys_interval; +/// +/// let (lo, hi) = jeffreys_interval(50, 200, 0.95); +/// assert!(lo < 0.25 && 0.25 < hi); +/// +/// // Zero successes: lower bound is exactly 0. +/// let (lo, hi) = jeffreys_interval(0, 100, 0.95); +/// assert_eq!(lo, 0.0); +/// assert!(hi < 0.05); +/// ``` +#[must_use] +#[allow(clippy::cast_precision_loss)] +// Cast is safe: the trials cap keeps counts far below f64 mantissa precision +pub fn jeffreys_interval(successes: u64, trials: u64, confidence: f64) -> (f64, f64) { + const MAX_TRIALS: u64 = 1_000_000_000_000; + + assert!(trials > 0, "jeffreys_interval requires trials > 0"); + assert!( + trials <= MAX_TRIALS, + "jeffreys_interval supports at most {MAX_TRIALS} trials (got {trials}); the \ + incomplete-beta evaluation is not validated beyond that scale" + ); + assert!( + successes <= trials, + "jeffreys_interval requires successes ({successes}) <= trials ({trials})" + ); + assert!( + confidence > 0.0 && confidence < 1.0, + "jeffreys_interval requires confidence in (0, 1), got {confidence}" + ); + + let alpha = 1.0 - confidence; + let a = successes as f64 + 0.5; + let b = (trials - successes) as f64 + 0.5; + + let lower = if successes == 0 { + 0.0 + } else { + betainc_inv(a, b, alpha / 2.0) + }; + let upper = if successes == trials { + 1.0 + } else { + betainc_inv(a, b, 1.0 - alpha / 2.0) + }; + assert!( + lower <= upper, + "jeffreys_interval produced inverted bounds ({lower} > {upper}) for k={successes}, \ + n={trials}; this indicates incomplete-beta breakdown and is a bug" + ); + (lower, upper) +} + /// Calculate the arithmetic mean of a slice of values. /// /// # Arguments @@ -602,6 +692,94 @@ mod tests { use super::*; use ndarray::Axis; + // Reference values generated with: + // uv run python -c "from scipy import stats; + // print(stats.beta.ppf(q, k + 0.5, n - k + 0.5))" + #[test] + fn jeffreys_interval_matches_scipy_beta_quantiles() { + let cases: [(u64, u64, f64, f64, f64); 6] = [ + (0, 100, 0.95, 0.0, 0.024_745_270_015_269_89), + (100, 100, 0.95, 0.975_254_729_984_730_1, 1.0), + ( + 3, + 1000, + 0.95, + 0.000_845_634_801_829_834_8, + 0.007_984_367_358_403_443, + ), + ( + 50, + 200, + 0.95, + 0.193_872_680_411_726_73, + 0.313_302_662_892_847_86, + ), + // High-confidence intervals at LER-study scales. + ( + 7, + 20_000, + 0.99999, + 3.838_996_822_347_517e-5, + 1.307_358_543_951_447_7e-3, + ), + ( + 1234, + 20_000, + 0.99999, + 0.054_475_933_954_188_78, + 0.069_508_522_504_658_04, + ), + ]; + for (k, n, conf, lo_expected, hi_expected) in cases { + let (lo, hi) = jeffreys_interval(k, n, conf); + let lo_scale = lo_expected.abs().max(1e-12); + let hi_scale = hi_expected.abs().max(1e-12); + assert!( + (lo - lo_expected).abs() / lo_scale < 1e-6, + "lower bound for k={k}, n={n}: expected {lo_expected:.12e}, got {lo:.12e}" + ); + assert!( + (hi - hi_expected).abs() / hi_scale < 1e-6, + "upper bound for k={k}, n={n}: expected {hi_expected:.12e}, got {hi:.12e}" + ); + } + } + + #[test] + fn jeffreys_interval_brackets_the_point_estimate() { + let (lo, hi) = jeffreys_interval(50, 200, 0.95); + assert!(lo < 0.25 && 0.25 < hi); + // Wider confidence gives a wider interval. + let (lo99, hi99) = jeffreys_interval(50, 200, 0.99); + assert!(lo99 < lo && hi < hi99); + } + + #[test] + #[should_panic(expected = "trials > 0")] + fn jeffreys_interval_rejects_zero_trials() { + let _ = jeffreys_interval(0, 0, 0.95); + } + + #[test] + #[should_panic(expected = "at most")] + fn jeffreys_interval_rejects_unvalidated_trial_scales() { + // Beyond ~1e12 trials the incomplete-beta continued fraction can + // converge spuriously (observed at 2e15: inverted bounds). + let _ = jeffreys_interval(1_000_000_000_000_000, 2_000_000_000_000_000, 0.95); + } + + #[test] + #[should_panic(expected = "successes")] + fn jeffreys_interval_rejects_successes_above_trials() { + let _ = jeffreys_interval(11, 10, 0.95); + } + + #[test] + #[should_panic(expected = "confidence")] + fn jeffreys_interval_rejects_bad_confidence() { + let _ = jeffreys_interval(5, 10, 1.0); + } + // Allow exact float comparisons in tests - we're testing mathematically exact results // that are exactly representable in IEEE 754 (e.g., 3.0, 42.0, 0.4) #[allow(clippy::float_cmp)] diff --git a/crates/pecos-phir-pliron/Cargo.toml b/crates/pecos-phir-pliron/Cargo.toml new file mode 100644 index 000000000..c32d7f468 --- /dev/null +++ b/crates/pecos-phir-pliron/Cargo.toml @@ -0,0 +1,23 @@ +# Parallel, non-default QIS-LLVM-IR -> pliron-PHIR path (the strangler crate, scoped to the covered +# QIS-LLVM subset). Lowers to a pliron `qec` dialect and runs through the UNCHANGED pecos-engines +# seam. pliron comes from crates.io, pinned by Cargo.lock. +# See pecos-docs design/slr-phir-pliron-strangler-scope.md + slr-phir-vision.md. +[package] +name = "pecos-phir-pliron" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +publish = false + +[dependencies] +pecos-engines.workspace = true +pecos-core.workspace = true +pliron = "0.15" +awint = "0.18" + +[dev-dependencies] +# The existing QIS->PHIR->sim path, for the strangler differential (compare this port against it). +pecos-phir.workspace = true diff --git a/crates/pecos-phir-pliron/fixtures/adaptive_branch.ll b/crates/pecos-phir-pliron/fixtures/adaptive_branch.ll new file mode 100644 index 000000000..7c430b1c9 --- /dev/null +++ b/crates/pecos-phir-pliron/fixtures/adaptive_branch.ll @@ -0,0 +1,28 @@ +; Adaptive program whose conditional branch IS taken (unlike qprog.ll, whose q0 is deterministic). +; h q0 -> measure q0 (random) -> if q0==1 { x q1 } -> measure q1. Invariant: final_q1 == mid_q0. +declare void @__quantum__qis__h__body(i64) +declare void @__quantum__qis__x__body(i64) +declare i32 @__quantum__qis__m__body(i64, i64) +declare void @__quantum__rt__result_record_output(i64, i8*) + +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x + +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final + +skip_x: + br label %final + +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 2, i8* null) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} + +attributes #0 = { "EntryPoint" } diff --git a/crates/pecos-phir-pliron/fixtures/branch_measure.ll b/crates/pecos-phir-pliron/fixtures/branch_measure.ll new file mode 100644 index 000000000..363e8c499 --- /dev/null +++ b/crates/pecos-phir-pliron/fixtures/branch_measure.ll @@ -0,0 +1,37 @@ +; Adaptive program with a measurement INSIDE the conditional branch. Coverage for the engine's +; outcome reconstruction when b2's length depends on which branch ran (taken: [m1, f0, f1]; +; skipped: [f0, f1]) -- the j-index must walk the actually-emitted measures, not a fixed layout. +; h q0 -> mid = m(q0) -> if mid==1 { x q1; m1 = m(q1, rid 3) } -> final m(q0, rid 0); m(q1, rid 1) +; The in-branch m1 is measured for side effect only and is NOT recorded: recording a value defined +; inside a qec.if region from the outer block is a cross-region SSA escape that needs block-args / +; yield (measurement-SSA Phase 2), out of scope here. Recorded outputs are the unconditional finals. +; Invariants: final_q0 (r0) == mid and final_q1 (r1) == mid in every shot (the in-branch x+measure +; on q1 leaves q1 == mid, so the later final measure still reads mid). +declare void @__quantum__qis__h__body(i64) +declare void @__quantum__qis__x__body(i64) +declare i32 @__quantum__qis__m__body(i64, i64) +declare void @__quantum__rt__result_record_output(i64, i8*) + +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x + +apply_x: + call void @__quantum__qis__x__body(i64 1) + %m1 = call i32 @__quantum__qis__m__body(i64 1, i64 3) + br label %final + +skip_x: + br label %final + +final: + %f0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} + +attributes #0 = { "EntryPoint" } diff --git a/crates/pecos-phir-pliron/fixtures/cz_swap.ll b/crates/pecos-phir-pliron/fixtures/cz_swap.ll new file mode 100644 index 000000000..166d0e969 --- /dev/null +++ b/crates/pecos-phir-pliron/fixtures/cz_swap.ll @@ -0,0 +1,28 @@ +; Straight-line program exercising the widened gate set (cz, swap, x) with a deterministic outcome. +; h q0; x q1; cz q0,q1; h q0; swap q1,q2; measure all +; Walkthrough: q0=|+>; q1=|1>; cz with control q1=|1> applies Z to q0 (|+> -> |->); h q0 maps |-> -> |1> +; (so q0 measures 1); swap q1,q2 moves the |1> from q1 to q2 (q1 -> 0, q2 -> 1). +; Deterministic: r0 == 1, r1 == 0, r2 == 1. +declare void @__quantum__qis__h__body(i64) +declare void @__quantum__qis__x__body(i64) +declare void @__quantum__qis__cz__body(i64, i64) +declare void @__quantum__qis__swap__body(i64, i64) +declare i32 @__quantum__qis__m__body(i64, i64) +declare void @__quantum__rt__result_record_output(i64, i8*) + +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + call void @__quantum__qis__x__body(i64 1) + call void @__quantum__qis__cz__body(i64 0, i64 1) + call void @__quantum__qis__h__body(i64 0) + call void @__quantum__qis__swap__body(i64 1, i64 2) + %r0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + %r1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + %r2 = call i32 @__quantum__qis__m__body(i64 2, i64 2) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + call void @__quantum__rt__result_record_output(i64 2, i8* null) + ret i64 0 +} + +attributes #0 = { "EntryPoint" } diff --git a/crates/pecos-phir-pliron/fixtures/ghz3.ll b/crates/pecos-phir-pliron/fixtures/ghz3.ll new file mode 100644 index 000000000..3c3a4d518 --- /dev/null +++ b/crates/pecos-phir-pliron/fixtures/ghz3.ll @@ -0,0 +1,22 @@ +; 3-qubit GHZ (straight-line, no conditional branch) -- exercises dynamic qubit count past the +; 2-qubit fixtures: h q0; cx q0,q1; cx q1,q2; measure all; record. State is (|000>+|111>)/sqrt(2), +; so the three recorded results are perfectly correlated: r0 == r1 == r2 in every shot. +declare void @__quantum__qis__h__body(i64) +declare void @__quantum__qis__cx__body(i64, i64) +declare i32 @__quantum__qis__m__body(i64, i64) +declare void @__quantum__rt__result_record_output(i64, i8*) + +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + call void @__quantum__qis__cx__body(i64 0, i64 1) + call void @__quantum__qis__cx__body(i64 1, i64 2) + %r0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + %r1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + %r2 = call i32 @__quantum__qis__m__body(i64 2, i64 2) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + call void @__quantum__rt__result_record_output(i64 2, i8* null) + ret i64 0 +} + +attributes #0 = { "EntryPoint" } diff --git a/crates/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs new file mode 100644 index 000000000..801716a34 --- /dev/null +++ b/crates/pecos-phir-pliron/src/lib.rs @@ -0,0 +1,3552 @@ +//! Parallel, non-default QIS-LLVM-IR -> pliron-PHIR path (the strangler crate, scoped to the +//! covered QIS-LLVM subset). See pecos-docs design/slr-phir-pliron-strangler-scope.md. +//! +//! A QIS-LLVM-IR program is lowered to a pliron `qec` dialect (allocator/slot model; measurements +//! are SSA values with metadata in a side-table registry; `result_record_output -> qec.record` +//! export) and run through the UNCHANGED pecos-engines seam (`ClassicalControlEngine` / +//! `ByteMessage` / `Shot`) -- no pliron type leaks into the engine API. Qubit identity comes from an +//! explicit `Value -> qubit index` map, NOT the `SSAValue`-as-u32 overload the incumbent uses. +//! +//! [`from_qis_llvm_ir_pliron`] is the narrow opt-in entry point: `.ll` text -> a boxed engine ready +//! for `HybridEngineBuilder`. The milestones M0-M7 + differential remain as the regression suite. + +use std::any::Any; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use awint::bw; +use pecos_core::Angle64; +use pecos_engines::byte_message::ByteMessage; +use pecos_engines::hybrid::builder::HybridEngineBuilder; +use pecos_engines::quantum::StateVecEngine; +use pecos_engines::{ + ClassicalControlEngine, ClassicalEngine, ControlEngine, Data, Engine, EngineStage, PecosError, + Shot, +}; +use pliron::{ + attribute::AttrObj, + basic_block::BasicBlock, + builtin::{ + attributes::IntegerAttr, + op_interfaces::{ + IsTerminatorInterface, NOpdsInterface, NRegionsInterface, NResultsInterface, + NoTerminatorInterface, OneResultInterface, SingleBlockRegionInterface, + }, + ops::{FuncOp, ModuleOp}, + types::{FunctionType, IntegerType, Signedness}, + }, + common_traits::Verify, + context::{Context, Ptr}, + derive::{pliron_attr, pliron_op, pliron_type}, + linked_list::ContainsLinkedList, + op::{Op, verify_op}, + operation::Operation, + printable::Printable, + result::Result, + r#type::{TypeObj, TypePtr, Typed}, + utils::apint::APInt, + value::Value, + verify_err, +}; + +// ===================== Milestone 0: hand-built Bell ByteMessage ===================== + +pub fn bell_message() -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + b.h(&[0]); + b.cx(&[(0, 1)]); + b.mz(&[0]); + b.mz(&[1]); + b.build() +} + +/// Run a Bell ByteMessage through the real state-vector simulator; assert each shot is 00 or 11. +pub fn run_and_check(label: &str, msg: ByteMessage, shots: usize) { + let mut engine = StateVecEngine::new(2); + let (mut saw0, mut saw3) = (false, false); + for _ in 0..shots { + engine.reset().unwrap(); + let out = engine.process(msg.clone()).unwrap(); + let o = out.outcomes().unwrap(); + assert_eq!(o.len(), 2, "expected 2 measurement outcomes"); + let combined = (o[1] << 1) | o[0]; // q1 q0 + assert!( + combined == 0 || combined == 3, + "{label}: Bell must be 00 or 11, got {combined}" + ); + saw0 |= combined == 0; + saw3 |= combined == 3; + } + assert!( + saw0 && saw3, + "{label}: expected both 00 and 11 over {shots} shots" + ); + println!("[{label}] OK -- all {shots} shots in {{0,3}}, saw both 00 and 11"); +} + +// ===================== the qec pliron dialect (allocator/slot model) ===================== + +#[pliron_type(name = "qec.alloc", generate_get = true, format, verifier = "succ")] +#[derive(Hash, PartialEq, Eq, Debug)] +pub struct AllocType; + +#[pliron_type(name = "qec.qubitref", generate_get = true, format, verifier = "succ")] +#[derive(Hash, PartialEq, Eq, Debug)] +pub struct QubitRefType; + +pub fn alloc_ty(ctx: &Context) -> Ptr { + AllocType::get(ctx).into() +} +pub fn qubitref_ty(ctx: &Context) -> Ptr { + QubitRefType::get(ctx).into() +} + +mod slot_attr { + use pliron::dict_key; + dict_key!(INDEX, "qec_slot_index"); +} + +mod angle_attr { + use pliron::dict_key; + dict_key!(ANGLE, "qec_angle"); +} + +/// A PECOS-native angle attribute: stores `Angle64`'s fixed-point *fraction* (a fraction of a full +/// turn, full circle = `2^64`) exactly -- no lossy f64 round-trip in the IR. `qec.rz/rx/ry` carry one. +#[pliron_attr(name = "qec.angle", format = "$0", verifier = "succ")] +#[derive(PartialEq, Eq, Clone, Debug, Hash)] +pub struct Angle64Attr(u64); +impl From for Angle64Attr { + fn from(a: Angle64) -> Self { + Angle64Attr(a.fraction()) + } +} +impl From for Angle64 { + fn from(a: Angle64Attr) -> Self { + Angle64::new(a.0) + } +} + +// ===================== measurement-SSA registry (measurement-id-system.md, Phase 1) ===================== +// A `qec.measure` op produces an SSA `Value` -- that value IS the measurement's identity. Per the +// design note, all per-measurement metadata (qubit, basis, export label) lives in a side table +// keyed by that Value, NOT bolted onto the op as attributes: ops stay lightweight, and the hot +// path (plan/engine) keys lookups by Value. No position-dependent indices anywhere. + +/// Measurement basis. Every measurement in the current QIS path is a Z measurement (`mz`); the field +/// gives the side-table a home for basis so a future X/Y measurement needs no schema change. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Basis { + X, + Y, + Z, +} + +/// Side-table metadata for one measurement-SSA value (kept off the op, per the design note). +#[derive(Clone, Copy, Debug)] +pub struct MeasurementInfo { + pub qubit: usize, + pub basis: Basis, + pub export_label: u64, // QIS result-id: the name this measurement records under +} + +/// The circuit's measurement side-table: `measurement-SSA Value -> MeasurementInfo`. Populated as +/// `qec.measure` ops are built; the plan/engine look measurements up by Value. `HashMap` (not +/// `BTreeMap`) because pliron `Value` is not `Ord`, and the table is only point-queried -- never +/// iterated for output, so there is no determinism concern (export order comes from `qec.record`). +#[derive(Clone, Default)] +pub struct MeasurementRegistry { + info: HashMap, +} +impl MeasurementRegistry { + /// Record a freshly built measurement-SSA value and its metadata. + pub fn record(&mut self, value: Value, info: MeasurementInfo) { + self.info.insert(value, info); + } + /// Look up a measurement-SSA value; panics (fail loud) if the value was never registered. + pub fn get(&self, value: Value) -> MeasurementInfo { + *self + .info + .get(&value) + .unwrap_or_else(|| panic!("measurement-SSA value not in the measurement registry")) + } +} + +/// Store a fixed-point `Angle64` on an op as a `qec.angle` attribute. +pub fn set_angle(ctx: &Context, op: Ptr, angle: Angle64) { + op.deref_mut(ctx).attributes.0.insert( + angle_attr::ANGLE.clone(), + Box::new(Angle64Attr::from(angle)), + ); +} +pub fn get_angle(ctx: &Context, op: Ptr) -> Angle64 { + let o = op.deref(ctx); + let a: AttrObj = o + .attributes + .0 + .get(&*angle_attr::ANGLE) + .expect("angle attr") + .clone(); + let aa = a + .downcast::() + .unwrap_or_else(|_| panic!("angle not Angle64Attr")); + Angle64::from(*aa) +} + +#[pliron_op(name = "qec.qalloc", format, interfaces = [NOpdsInterface<0>, OneResultInterface, NResultsInterface<1>], verifier = "succ")] +pub struct QallocOp; +impl QallocOp { + pub fn new(ctx: &mut Context) -> Self { + let a = alloc_ty(ctx); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![a], + vec![], + vec![], + 0, + ); + QallocOp { op } + } +} + +#[pliron_op(name = "qec.slot", format, interfaces = [NOpdsInterface<1>, OneResultInterface, NResultsInterface<1>])] +pub struct SlotOp; +impl SlotOp { + pub fn new(ctx: &mut Context, alloc: Value, index: u64) -> Self { + let r = qubitref_ty(ctx); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![r], + vec![alloc], + vec![], + 0, + ); + let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless); + let attr = IntegerAttr::new(i64_ty, APInt::from_u64(index, bw(64))); + op.deref_mut(ctx) + .attributes + .0 + .insert(slot_attr::INDEX.clone(), Box::new(attr)); + SlotOp { op } + } + pub fn index(&self, ctx: &Context) -> u64 { + let op = self.get_operation().deref(ctx); + let attr: AttrObj = op + .attributes + .0 + .get(&*slot_attr::INDEX) + .expect("slot index attr") + .clone(); + let int_attr = attr + .downcast::() + .unwrap_or_else(|_| panic!("not IntegerAttr")); + Into::::into(*int_attr).to_u64() + } +} +impl Verify for SlotOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != alloc_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.slot operand must be a qec.alloc"); + } + Ok(()) + } +} + +#[pliron_op(name = "qec.prepare", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct PrepareOp; +impl PrepareOp { + pub fn new(ctx: &mut Context, qref: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![qref], + vec![], + 0, + ); + PrepareOp { op } + } +} +impl Verify for PrepareOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.prepare operand must be a qec.qubitref"); + } + Ok(()) + } +} + +#[pliron_op(name = "qec.h", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct HOp; +impl HOp { + pub fn new(ctx: &mut Context, qref: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![qref], + vec![], + 0, + ); + HOp { op } + } +} +impl Verify for HOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.h operand must be a qec.qubitref"); + } + Ok(()) + } +} + +#[pliron_op(name = "qec.cx", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] +pub struct CxOp; +impl CxOp { + pub fn new(ctx: &mut Context, ctrl: Value, tgt: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![ctrl, tgt], + vec![], + 0, + ); + CxOp { op } + } +} +impl Verify for CxOp { + fn verify(&self, ctx: &Context) -> Result<()> { + for i in 0..2 { + if self.get_operation().deref(ctx).get_operand(i).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.cx operands must be qec.qubitref"); + } + } + Ok(()) + } +} + +#[pliron_op(name = "qec.cz", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] +pub struct CzOp; +impl CzOp { + pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![a, b], + vec![], + 0, + ); + CzOp { op } + } +} +impl Verify for CzOp { + fn verify(&self, ctx: &Context) -> Result<()> { + for i in 0..2 { + if self.get_operation().deref(ctx).get_operand(i).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.cz operands must be qec.qubitref"); + } + } + Ok(()) + } +} + +#[pliron_op(name = "qec.swap", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] +pub struct SwapOp; +impl SwapOp { + pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![a, b], + vec![], + 0, + ); + SwapOp { op } + } +} +impl Verify for SwapOp { + fn verify(&self, ctx: &Context) -> Result<()> { + for i in 0..2 { + if self.get_operation().deref(ctx).get_operand(i).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.swap operands must be qec.qubitref"); + } + } + Ok(()) + } +} + +/// `%result = qec.measure(qubitref)` -- a measurement. Its result `%result` IS the measurement-SSA +/// value (the identity); all per-measurement metadata (qubit, basis, export label) lives in the +/// `MeasurementRegistry` side-table keyed by that value, not on the op (ops stay lightweight). +#[pliron_op(name = "qec.measure", format, interfaces = [NOpdsInterface<1>, OneResultInterface, NResultsInterface<1>])] +pub struct MeasureOp; +impl MeasureOp { + pub fn new(ctx: &mut Context, qref: Value) -> Self { + let i1 = IntegerType::get(ctx, 1, Signedness::Signless); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![i1.into()], + vec![qref], + vec![], + 0, + ); + MeasureOp { op } + } +} +impl Verify for MeasureOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.measure operand must be a qec.qubitref"); + } + Ok(()) + } +} + +/// `qec.record(measurement-SSA)` -- mark a `qec.measure` result as a recorded program output +/// (lowered from QIS `__quantum__rt__result_record_output`). The textual order of these ops IS the +/// program's classical-output order; the operand makes the recorded measurement-SSA explicit. +#[pliron_op(name = "qec.record", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct RecordOp; +impl RecordOp { + pub fn new(ctx: &mut Context, result: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![result], + vec![], + 0, + ); + RecordOp { op } + } +} +impl Verify for RecordOp { + fn verify(&self, ctx: &Context) -> Result<()> { + let opnd = self.get_operation().deref(ctx).get_operand(0); + let records_a_measure = opnd + .defining_op() + .is_some_and(|o| Operation::get_op::(o, ctx).is_some()); + if !records_a_measure { + return verify_err!( + self.loc(ctx), + "qec.record operand must be a qec.measure result" + ); + } + // Region scope: the recorded measurement must be visible from this record op -- i.e. its + // defining block must be the record's block or an enclosing one. Recording a measurement + // defined inside a `qec.if` region from the outer block is a cross-region SSA escape that + // needs block-args/yield (measurement-SSA Phase 2); reject it rather than silently allow it. + let meas_block = opnd.get_defining_block(ctx); + let mut cur = self.get_operation().deref(ctx).get_parent_block(); + let visible = loop { + match cur { + Some(b) if Some(b) == meas_block => break true, + Some(b) => cur = b.deref(ctx).get_parent_block(ctx), + None => break false, + } + }; + if !visible { + return verify_err!( + self.loc(ctx), + "qec.record operand is defined in a non-enclosing region (cross-region escape needs yield)" + ); + } + Ok(()) + } +} + +/// `qec.cond_x(cond: i1, target: qubitref)` -- apply X to `target` iff the measurement result +/// `cond` is 1. The measurement-conditioned gate that forces a classical decision between batches. +#[pliron_op(name = "qec.cond_x", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] +pub struct CondXOp; +impl CondXOp { + pub fn new(ctx: &mut Context, cond: Value, target: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![cond, target], + vec![], + 0, + ); + CondXOp { op } + } +} +impl Verify for CondXOp { + fn verify(&self, ctx: &Context) -> Result<()> { + // condition (operand 0) must be an i1 (a measurement result); target (operand 1) a qubitref. + // We inspect the operand's actual type read-only (TypePtr::from_ptr + width) rather than + // construct the expected i1 -- constructing a parameterized type needs &mut Context. + let cond_ty = self.get_operation().deref(ctx).get_operand(0).get_type(ctx); + let cond_is_i1 = TypePtr::::from_ptr(cond_ty, ctx) + .is_ok_and(|tp| tp.deref(ctx).width() == 1); + if !cond_is_i1 { + return verify_err!( + self.loc(ctx), + "qec.cond_x condition (operand 0) must be an i1 measurement result" + ); + } + if self.get_operation().deref(ctx).get_operand(1).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.cond_x target must be a qec.qubitref"); + } + Ok(()) + } +} + +/// `qec.x(qubitref)` -- plain Pauli-X (used inside `qec.if` region blocks). +#[pliron_op(name = "qec.x", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct XOp; +impl XOp { + pub fn new(ctx: &mut Context, qref: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![qref], + vec![], + 0, + ); + XOp { op } + } +} +impl Verify for XOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.x operand must be a qec.qubitref"); + } + Ok(()) + } +} + +/// `qec.if(cond: i1) { then } { else }` -- region-based conditional control flow (vision-aligned; +/// no CFG flattening). Two single-block regions of qec ops, no terminators. +#[pliron_op(name = "qec.if", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>, NRegionsInterface<2>, SingleBlockRegionInterface, NoTerminatorInterface])] +pub struct IfOp; +impl IfOp { + pub fn new(ctx: &mut Context, cond: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![cond], + vec![], + 2, + ); + IfOp { op } + } + /// Create + attach a fresh single block for region `idx` and return it (for building). + pub fn make_region_block(&self, ctx: &mut Context, idx: usize) -> Ptr { + let region = self.get_operation().deref(ctx).get_region(idx); + let block = BasicBlock::new(ctx, None, vec![]); + block.insert_at_front(region, ctx); + block + } +} +impl Verify for IfOp { + fn verify(&self, ctx: &Context) -> Result<()> { + // the condition (operand 0) must be an i1 measurement result (region count is enforced by + // NRegionsInterface<2>); inspect the operand's type read-only, do not construct it. + let cond_ty = self.get_operation().deref(ctx).get_operand(0).get_type(ctx); + let is_i1 = TypePtr::::from_ptr(cond_ty, ctx) + .is_ok_and(|tp| tp.deref(ctx).width() == 1); + if !is_i1 { + return verify_err!( + self.loc(ctx), + "qec.if condition (operand 0) must be an i1 measurement result" + ); + } + Ok(()) + } +} + +/// Single-qubit rotations carrying a fixed-point `Angle64` (qprog.ll's rz/rx/ry). +#[pliron_op(name = "qec.rz", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct RzOp; +impl RzOp { + pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![q], + vec![], + 0, + ); + set_angle(ctx, op, angle); + RzOp { op } + } +} +impl Verify for RzOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.rz operand must be a qec.qubitref"); + } + Ok(()) + } +} +#[pliron_op(name = "qec.rx", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct RxOp; +impl RxOp { + pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![q], + vec![], + 0, + ); + set_angle(ctx, op, angle); + RxOp { op } + } +} +impl Verify for RxOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.rx operand must be a qec.qubitref"); + } + Ok(()) + } +} +#[pliron_op(name = "qec.ry", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct RyOp; +impl RyOp { + pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![q], + vec![], + 0, + ); + set_angle(ctx, op, angle); + RyOp { op } + } +} +impl Verify for RyOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.ry operand must be a qec.qubitref"); + } + Ok(()) + } +} +/// Two-qubit sqrt-ZZ entangler (PECOS `SZZ`; qprog.ll's no-angle `zz` maps here). +#[pliron_op(name = "qec.szz", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>], verifier = "succ")] +pub struct SzzOp; +impl SzzOp { + pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![a, b], + vec![], + 0, + ); + SzzOp { op } + } +} + +#[pliron_op(name = "qec.end", format, interfaces = [IsTerminatorInterface, NOpdsInterface<0>, NResultsInterface<0>], verifier = "succ")] +pub struct EndOp; +impl EndOp { + pub fn new(ctx: &mut Context) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![], vec![], 0); + EndOp { op } + } +} + +// ===================== Milestone 1: build Bell as pliron IR, emit ByteMessage ===================== + +/// Build `fn main() { q = qalloc; q0 = slot(q,0); q1 = slot(q,1); prepare q0; prepare q1; +/// h q0; cx q0,q1; m0 = measure q0; m1 = measure q1; end }` as pliron IR; return the func block. +pub fn build_bell_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { + let module = ModuleOp::new(ctx, "bell".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + + macro_rules! push { + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; + } + + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let s0 = push!(SlotOp::new(ctx, qv, 0)); + let s0v = s0.get_result(ctx); + let s1 = push!(SlotOp::new(ctx, qv, 1)); + let s1v = s1.get_result(ctx); + push!(PrepareOp::new(ctx, s0v)); + push!(PrepareOp::new(ctx, s1v)); + push!(HOp::new(ctx, s0v)); + push!(CxOp::new(ctx, s0v, s1v)); + push!(MeasureOp::new(ctx, s0v)); + push!(MeasureOp::new(ctx, s1v)); + push!(EndOp::new(ctx)); + (module, bb) +} + +/// Walk the pliron func block and emit a ByteMessage. The ONLY qubit-identity source is an +/// explicit `Value(qubitref) -> qubit index` map populated from each `qec.slot`'s index +/// attribute -- pliron Values are op-defined handles, never reused as dense qubit indices. +pub fn emit_bytemessage(ctx: &Context, block: Ptr) -> ByteMessage { + let mut qubit_of: HashMap = HashMap::new(); + let mut b = ByteMessage::quantum_operations_builder(); + let ops: Vec> = block.deref(ctx).iter(ctx).collect(); + for op in ops { + if let Some(s) = Operation::get_op::(op, ctx) { + qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); + } else if let Some(p) = Operation::get_op::(op, ctx) { + let q = qubit_of[&p.get_operation().deref(ctx).get_operand(0)]; + b.pz(&[q]); + } else if let Some(h) = Operation::get_op::(op, ctx) { + let q = qubit_of[&h.get_operation().deref(ctx).get_operand(0)]; + b.h(&[q]); + } else if let Some(cx) = Operation::get_op::(op, ctx) { + let opn = cx.get_operation(); + let c = qubit_of[&opn.deref(ctx).get_operand(0)]; + let t = qubit_of[&opn.deref(ctx).get_operand(1)]; + b.cx(&[(c, t)]); + } else if let Some(m) = Operation::get_op::(op, ctx) { + let q = qubit_of[&m.get_operation().deref(ctx).get_operand(0)]; + b.mz(&[q]); // outcome order = mz-emission order + } + // qalloc / end: nothing to emit + } + b.build() +} + +pub fn run_milestone_1() { + let ctx = &mut Context::new(); + let (module, bb) = build_bell_ir(ctx); + println!("=== Bell as pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + match verify_op(&module, ctx) { + Ok(()) => println!("[milestone-1 verify] OK"), + Err(e) => panic!("[milestone-1 verify] FAILED: {}", e.disp(ctx)), + } + let msg = emit_bytemessage(ctx, bb); + run_and_check("milestone-1 pliron-emitted Bell", msg, 200); +} + +// ===================== Milestone 2: pliron op-walk AS a real ClassicalEngine ===================== + +/// A `ClassicalControlEngine` whose command stream is the ByteMessage emitted from the pliron +/// `qec` IR walk. Bell is straight-line (one batch, no classical feedback), so it sends the +/// whole program once, stores the outcomes, and records register "c" = (q1<<1)|q0 as a Shot. +#[derive(Clone)] +pub struct PlironBellEngine { + msg: ByteMessage, + num_qubits: usize, + sent: bool, + outcomes: Vec, +} + +impl Engine for PlironBellEngine { + type Input = (); + type Output = Shot; + fn process(&mut self, _input: ()) -> std::result::Result { + self.get_results() + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + self.sent = false; + self.outcomes.clear(); + Ok(()) + } +} + +impl ClassicalEngine for PlironBellEngine { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn generate_commands(&mut self) -> std::result::Result { + if self.sent { + Ok(ByteMessage::create_empty()) + } else { + self.sent = true; + Ok(self.msg.clone()) + } + } + fn handle_measurements(&mut self, message: ByteMessage) -> std::result::Result<(), PecosError> { + self.outcomes = message.outcomes()?; + Ok(()) + } + fn get_results(&self) -> std::result::Result { + let combined = if self.outcomes.len() >= 2 { + (self.outcomes[1] << 1) | self.outcomes[0] + } else { + 0 + }; + let mut shot = Shot::default(); + shot.add_register("c", combined, 2); + Ok(shot) + } + fn compile(&self) -> std::result::Result<(), PecosError> { + Ok(()) + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +impl ControlEngine for PlironBellEngine { + type Input = (); + type Output = Shot; + type EngineInput = ByteMessage; + type EngineOutput = ByteMessage; + fn start( + &mut self, + _input: (), + ) -> std::result::Result, PecosError> { + self.sent = false; + self.outcomes.clear(); + let cmds = self.generate_commands()?; + if cmds.as_bytes().is_empty() { + Ok(EngineStage::Complete(self.get_results()?)) + } else { + Ok(EngineStage::NeedsProcessing(cmds)) + } + } + fn continue_processing( + &mut self, + measurements: ByteMessage, + ) -> std::result::Result, PecosError> { + self.handle_measurements(measurements)?; + // Bell is a single batch with no classical feedback: once the (only) batch's measurements + // are in, we are done. (A general engine would loop on generate_commands + a `finished` + // flag like PhirEngine; `create_empty().as_bytes()` is NOT byte-empty, so it is not a + // reliable termination signal on its own.) + Ok(EngineStage::Complete(self.get_results()?)) + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } +} + +pub fn run_milestone_2() { + let ctx = &mut Context::new(); + let (module, bb) = build_bell_ir(ctx); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-2 verify] FAILED: {}", e.disp(ctx))); + let msg = emit_bytemessage(ctx, bb); // pliron-emitted Bell ByteMessage + + let mut engine = PlironBellEngine { + msg, + num_qubits: 2, + sent: false, + outcomes: Vec::new(), + }; + let mut qsys = StateVecEngine::new(2); + + // Drive the ControlEngine protocol by hand (start -> {process, continue_processing} loop -> + // Complete) -- this IS the engine seam HybridEngine automates; doing it explicitly proves the + // pliron-backed ClassicalControlEngine feeds the real StateVecEngine and yields a Shot. + let (mut saw0, mut saw3) = (false, false); + for _ in 0..200 { + qsys.reset().unwrap(); + let mut stage = ControlEngine::start(&mut engine, ()).unwrap(); + let shot = loop { + match stage { + EngineStage::Complete(s) => break s, + EngineStage::NeedsProcessing(cmd) => { + let meas = qsys.process(cmd).unwrap(); + stage = engine.continue_processing(meas).unwrap(); + } + } + }; + let v = shot + .data + .get("c") + .and_then(Data::as_u32) + .expect("register c"); + assert!( + v == 0 || v == 3, + "milestone-2: Bell via ClassicalEngine must be 00 or 11, got {v}" + ); + saw0 |= v == 0; + saw3 |= v == 3; + } + assert!( + saw0 && saw3, + "milestone-2: expected both 00 and 11 over 200 shots" + ); + println!( + "[milestone-2 pliron ClassicalControlEngine -> StateVecEngine] OK -- 200 shots in {{0,3}}, saw both (register \"c\")" + ); +} + +// ===================== Milestone 3: parse a real bell.ll into pliron qec IR ===================== + +/// Structured rejection for anything outside the covered QIS-LLVM subset (see the strangler scope +/// doc) -- used instead of silently dropping unrecognized calls or panicking on malformed structure. +fn unsupported_qis(msg: impl Into) -> PecosError { + PecosError::Feature(format!( + "pecos-phir-pliron: unsupported QIS-LLVM-IR -- {}", + msg.into() + )) +} + +/// Minimal QIS-LLVM-IR -> pliron `qec` IR parser for the Bell-style straight-line subset. Recognizes +/// `__quantum__qis__{h,cx,m}__body` + `__quantum__rt__result_record_output`; any other `__quantum__` +/// call (or a malformed operand list) is rejected with a structured error rather than dropped. +pub fn parse_bell_ll( + ctx: &mut Context, + src: &str, +) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { + validate_straight_line_qis_shape(src)?; + + fn i64_args(line: &str) -> Vec { + match (line.find('('), line.rfind(')')) { + (Some(l), Some(r)) if r > l => line[l + 1..r] + .split(',') + .filter_map(|t| { + t.trim() + .strip_prefix("i64 ") + .and_then(|n| n.trim().parse::().ok()) + }) + .collect(), + _ => Vec::new(), + } + } + // pass 1: collect the gate/measure/record stream + the set of qubits referenced + let mut parsed: Vec<(&str, Vec)> = Vec::new(); + let mut qubits: BTreeSet = BTreeSet::new(); + let mut in_func = false; + for line in src.lines() { + let l = line.trim(); + if l.starts_with("define ") && l.contains("@qmain") { + in_func = true; + continue; + } + if !in_func { + continue; + } + if l == "}" { + break; + } + if !(l.starts_with("call ") || l.contains("= call ")) { + continue; + } + let a = i64_args(l); + if l.contains("__quantum__qis__h__body") { + let q = *a + .first() + .ok_or_else(|| unsupported_qis("h__body missing qubit operand"))?; + qubits.insert(q); + parsed.push(("h", a)); + } else if l.contains("__quantum__qis__x__body") { + let q = *a + .first() + .ok_or_else(|| unsupported_qis("x__body missing qubit operand"))?; + qubits.insert(q); + parsed.push(("x", a)); + } else if l.contains("__quantum__qis__cx__body") { + if a.len() < 2 { + return Err(unsupported_qis("cx__body needs two qubit operands")); + } + qubits.insert(a[0]); + qubits.insert(a[1]); + parsed.push(("cx", a)); + } else if l.contains("__quantum__qis__cz__body") { + if a.len() < 2 { + return Err(unsupported_qis("cz__body needs two qubit operands")); + } + qubits.insert(a[0]); + qubits.insert(a[1]); + parsed.push(("cz", a)); + } else if l.contains("__quantum__qis__swap__body") { + if a.len() < 2 { + return Err(unsupported_qis("swap__body needs two qubit operands")); + } + qubits.insert(a[0]); + qubits.insert(a[1]); + parsed.push(("swap", a)); + } else if l.contains("__quantum__qis__m__body") { + if a.len() < 2 { + return Err(unsupported_qis("m__body needs (qubit, result_id) operands")); + } + qubits.insert(a[0]); + parsed.push(("m", a)); + } else if l.contains("__quantum__rt__result_record_output") { + if a.is_empty() { + return Err(unsupported_qis( + "result_record_output missing result_id operand", + )); + } + parsed.push(("record", a)); + } else if l.contains("__quantum__") { + return Err(unsupported_qis(format!( + "operation not in the covered subset: {l}" + ))); + } + } + // pass 2: build the pliron qec IR + the measurement-SSA registry + let module = ModuleOp::new(ctx, "bell_from_ll".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "qmain".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; + } + + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let mut slot_of: HashMap = HashMap::new(); + for &idx in &qubits { + let s = push!(SlotOp::new(ctx, qv, idx as u64)); + let sv = s.get_result(ctx); + slot_of.insert(idx, sv); + push!(PrepareOp::new(ctx, sv)); // QIS qubits start |0>; that implicit init IS a prepare in the qec model + } + let mut reg = MeasurementRegistry::default(); + let mut measured: HashMap = HashMap::new(); // result-id -> measurement-SSA Value + for (op, a) in &parsed { + match *op { + "h" => { + push!(HOp::new(ctx, slot_of[&a[0]])); + } + "x" => { + push!(XOp::new(ctx, slot_of[&a[0]])); + } + "cx" => { + push!(CxOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); + } + "cz" => { + push!(CzOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); + } + "swap" => { + push!(SwapOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); + } + "m" => { + let m = push!(MeasureOp::new(ctx, slot_of[&a[0]])); + let v = m.get_result(ctx); + let rid = a[1] as u64; + measured.insert(rid, v); + reg.record( + v, + MeasurementInfo { + qubit: a[0], + basis: Basis::Z, + export_label: rid, + }, + ); + } + "record" => { + let rid = a[0] as u64; + let v = *measured.get(&rid).ok_or_else(|| { + unsupported_qis(format!( + "result_record_output references unknown result-id {rid}" + )) + })?; + push!(RecordOp::new(ctx, v)); + } + _ => {} + } + } + push!(EndOp::new(ctx)); + Ok((module, bb, reg)) +} + +pub fn run_milestone_3() { + let ctx = &mut Context::new(); + let src = include_str!("../../../examples/llvm/bell.ll"); + let (module, bb, _reg) = parse_bell_ll(ctx, src).expect("milestone-3: parse bell.ll"); + println!("=== bell.ll parsed into pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-3 verify] FAILED: {}", e.disp(ctx))); + let msg = emit_bytemessage(ctx, bb); + run_and_check("milestone-3 bell.ll -> pliron qec -> sim", msg, 200); +} + +// ===================== Milestone 4: adaptive, multi-batch, measurement-conditioned ===================== +// Program: prepare q0,q1; h q0; m0 = measure q0; cond_x(m0, q1); mf1 = measure q1. +// The engine must send batch1 (gates + mz q0), get m0, make a CLASSICAL decision, then send a +// SECOND batch (conditionally x q1, then mz q1). Invariant: mf1 == m0 (q1 flips iff m0==1) -- which +// only holds if the measurement-conditioned feedback works across the two quantum batches. + +#[derive(Clone, Copy)] +pub enum Cmd { + Pz(usize), + H(usize), + X(usize), + Rz(usize, Angle64), + Rx(usize, Angle64), + Ry(usize, Angle64), + Szz(usize, usize), + Cx(usize, usize), + Cz(usize, usize), + Swap(usize, usize), + Mz(usize, u64), // (qubit, QIS result-id); the id rides through to the export/register mapping +} + +#[derive(Clone)] +pub struct AdaptivePlan { + batch1: Vec, // gates + the conditioning Mz + cond_outcome_idx: usize, // index in batch1's mz-outcomes that gates cond_x + cond_target: usize, // qubit to X iff that outcome == 1 + batch2: Vec, // post-condition ops (final Mz) +} + +/// Build the `Cmd::Mz` for a `qec.measure`, asserting the registry's qubit agrees with the IR slot +/// index. The registry is a side-table, so `verify` can't catch a mismatch; this fails loud at +/// plan-build time if the registry and the IR ever drift apart. +fn measure_to_mz( + ctx: &Context, + m: MeasureOp, + qubit_of: &HashMap, + reg: &MeasurementRegistry, +) -> Cmd { + let operand = m.get_operation().deref(ctx).get_operand(0); + let slot_qubit = qubit_of[&operand]; + let info = reg.get(m.get_result(ctx)); + assert_eq!( + info.qubit, slot_qubit, + "measurement registry qubit ({}) disagrees with the IR slot index ({}) -- registry/IR drift", + info.qubit, slot_qubit + ); + Cmd::Mz(info.qubit, info.export_label) +} + +/// Walk a `qec` block once and split it into the two-batch adaptive plan at the `cond_x` boundary. +/// Gate qubits come from the explicit slot-index map; measurement metadata (qubit + export label) +/// comes from the `MeasurementRegistry`, keyed by the measure op's SSA value. +pub fn plan_from_ir( + ctx: &Context, + block: Ptr, + reg: &MeasurementRegistry, +) -> AdaptivePlan { + let mut qubit_of: HashMap = HashMap::new(); + let (mut batch1, mut batch2): (Vec, Vec) = (Vec::new(), Vec::new()); + let mut after_cond = false; + let mut mz_b1 = 0usize; + let (mut cond_outcome_idx, mut cond_target) = (0usize, 0usize); + let ops: Vec> = block.deref(ctx).iter(ctx).collect(); + for op in ops { + if let Some(s) = Operation::get_op::(op, ctx) { + qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); + } else if let Some(m) = Operation::get_op::(op, ctx) { + let mz = measure_to_mz(ctx, m, &qubit_of, reg); + if after_cond { + batch2.push(mz); + } else { + cond_outcome_idx = mz_b1; + mz_b1 += 1; + batch1.push(mz); + } + } else if let Some(c) = Operation::get_op::(op, ctx) { + cond_target = qubit_of[&c.get_operation().deref(ctx).get_operand(1)]; + after_cond = true; + } else if let Some(cmd) = gate_op_to_cmd(ctx, op, &qubit_of) { + if after_cond { + batch2.push(cmd) + } else { + batch1.push(cmd) + } + } + } + AdaptivePlan { + batch1, + cond_outcome_idx, + cond_target, + batch2, + } +} + +/// Number of qubits a Cmd stream touches = max referenced qubit index + 1 (0 if it touches none). +/// Lets an engine report its real `num_qubits()` instead of a hard-coded value. +pub fn cmds_num_qubits(batches: &[&[Cmd]]) -> usize { + let mut n = 0; + for cmds in batches { + for c in *cmds { + let hi = match *c { + Cmd::Pz(q) + | Cmd::H(q) + | Cmd::X(q) + | Cmd::Rz(q, _) + | Cmd::Rx(q, _) + | Cmd::Ry(q, _) + | Cmd::Mz(q, _) => q, + Cmd::Szz(a, b) | Cmd::Cx(a, b) | Cmd::Cz(a, b) | Cmd::Swap(a, b) => a.max(b), + }; + n = n.max(hi + 1); + } + } + n +} + +pub fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: &[Cmd]) { + for c in cmds { + match *c { + Cmd::Pz(q) => { + b.pz(&[q]); + } + Cmd::H(q) => { + b.h(&[q]); + } + Cmd::X(q) => { + b.x(&[q]); + } + Cmd::Rz(q, a) => { + b.rz(a, &[q]); + } + Cmd::Rx(q, a) => { + b.rx(a, &[q]); + } + Cmd::Ry(q, a) => { + b.ry(a, &[q]); + } + Cmd::Szz(a, c0) => { + b.szz(&[(a, c0)]); + } + Cmd::Cx(c0, t) => { + b.cx(&[(c0, t)]); + } + Cmd::Cz(a, c0) => { + b.cz(&[(a, c0)]); + } + Cmd::Swap(a, c0) => { + b.swap(&[(a, c0)]); + } + Cmd::Mz(q, _rid) => { + b.mz(&[q]); + } // result-id is bookkeeping, not a simulator op + } + } +} + +#[derive(Clone)] +pub struct PlironAdaptiveEngine { + plan: AdaptivePlan, + stage: u8, // 0 fresh, 1 batch1 sent, 2 batch2 sent + b1: Vec, + b2: Vec, +} + +impl PlironAdaptiveEngine { + fn batch1_msg(&self) -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + emit_cmds(&mut b, &self.plan.batch1); + b.build() + } + fn batch2_msg(&self) -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + // the classical decision: apply X iff the conditioning measurement was 1 + if self.b1.get(self.plan.cond_outcome_idx).copied() == Some(1) { + b.x(&[self.plan.cond_target]); + } + emit_cmds(&mut b, &self.plan.batch2); + b.build() + } +} + +impl Engine for PlironAdaptiveEngine { + type Input = (); + type Output = Shot; + fn process(&mut self, _i: ()) -> std::result::Result { + self.get_results() + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + self.stage = 0; + self.b1.clear(); + self.b2.clear(); + Ok(()) + } +} + +impl ClassicalEngine for PlironAdaptiveEngine { + fn num_qubits(&self) -> usize { + // gates/measures across both batches, plus the conditional-X target qubit. + cmds_num_qubits(&[&self.plan.batch1, &self.plan.batch2]).max(self.plan.cond_target + 1) + } + fn generate_commands(&mut self) -> std::result::Result { + if self.stage == 0 { + self.stage = 1; + Ok(self.batch1_msg()) + } else { + Ok(ByteMessage::create_empty()) + } + } + fn handle_measurements(&mut self, m: ByteMessage) -> std::result::Result<(), PecosError> { + let o = m.outcomes()?; + if self.stage == 1 { + self.b1 = o + } else { + self.b2 = o + } + Ok(()) + } + fn get_results(&self) -> std::result::Result { + let mut shot = Shot::default(); + shot.add_register("mid", self.b1.first().copied().unwrap_or(0), 1); + shot.add_register("final", self.b2.first().copied().unwrap_or(0), 1); + Ok(shot) + } + fn compile(&self) -> std::result::Result<(), PecosError> { + Ok(()) + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +impl ControlEngine for PlironAdaptiveEngine { + type Input = (); + type Output = Shot; + type EngineInput = ByteMessage; + type EngineOutput = ByteMessage; + fn start(&mut self, _i: ()) -> std::result::Result, PecosError> { + self.stage = 0; + self.b1.clear(); + self.b2.clear(); + self.stage = 1; + Ok(EngineStage::NeedsProcessing(self.batch1_msg())) + } + fn continue_processing( + &mut self, + meas: ByteMessage, + ) -> std::result::Result, PecosError> { + if self.stage == 1 { + self.b1 = meas.outcomes()?; + self.stage = 2; + Ok(EngineStage::NeedsProcessing(self.batch2_msg())) + } else { + self.b2 = meas.outcomes()?; + Ok(EngineStage::Complete(self.get_results()?)) + } + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } +} + +pub fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr, MeasurementRegistry) { + let module = ModuleOp::new(ctx, "adaptive".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; + } + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let s0 = push!(SlotOp::new(ctx, qv, 0)); + let s0v = s0.get_result(ctx); + let s1 = push!(SlotOp::new(ctx, qv, 1)); + let s1v = s1.get_result(ctx); + push!(PrepareOp::new(ctx, s0v)); + push!(PrepareOp::new(ctx, s1v)); + push!(HOp::new(ctx, s0v)); + let mut reg = MeasurementRegistry::default(); + let m0 = push!(MeasureOp::new(ctx, s0v)); // mid measure (conditioning) + let m0v = m0.get_result(ctx); + reg.record( + m0v, + MeasurementInfo { + qubit: 0, + basis: Basis::Z, + export_label: 0, + }, + ); + push!(CondXOp::new(ctx, m0v, s1v)); // X q1 iff m0 == 1 + let m1 = push!(MeasureOp::new(ctx, s1v)); // final measure + reg.record( + m1.get_result(ctx), + MeasurementInfo { + qubit: 1, + basis: Basis::Z, + export_label: 1, + }, + ); + push!(EndOp::new(ctx)); + (module, bb, reg) +} + +pub fn run_milestone_4() { + let ctx = &mut Context::new(); + let (module, bb, reg) = build_adaptive_ir(ctx); + println!("=== adaptive (mid-measure -> cond_x -> final) pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-4 verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_ir(ctx, bb, ®); + let engine = PlironAdaptiveEngine { + plan, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + + // Drive through the REAL HybridEngine this time (closes the "wrapper unrun" gap from round 3). + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + + let (mut all_eq, mut saw_mid0, mut saw_mid1) = (true, false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let mid = shot.data.get("mid").and_then(Data::as_u32).expect("mid"); + let fin = shot + .data + .get("final") + .and_then(Data::as_u32) + .expect("final"); + if fin != mid { + all_eq = false; + } + saw_mid0 |= mid == 0; + saw_mid1 |= mid == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!( + all_eq, + "milestone-4: final must equal mid in every shot (measurement-conditioned X feedback)" + ); + assert!( + saw_mid0 && saw_mid1, + "milestone-4: expected both mid=0 and mid=1 over 200 shots" + ); + println!( + "[milestone-4 adaptive multi-batch via HybridEngine] OK -- final==mid in all 200 shots, saw mid 0 and 1" + ); +} + +// ===================== Milestone 5: region-based conditional control flow (qec.if) ===================== +// Same adaptive program as M4, but the conditional is now a real pliron REGION op +// (`qec.if(cond) { then } { else }`), not a single cond_x op. The engine interprets the chosen +// region across batches. This proves region-based control flow (vision-aligned; no CFG flattening). + +/// Lower the ops of a single block (a `qec.if` region body) to a Cmd list, reusing the slot map. +/// Gate qubits come from `qubit_of`; measurement qubit + export label come from the registry. +/// Translate a single `qec` *gate* op to its runtime `Cmd`, or `None` if `op` is not a gate (a +/// `qec.slot`/`qec.if`/`qec.record`/`qec.qalloc`/`qec.end`/`qec.measure`). Shared by every plan +/// walker so none of them can silently drop a gate another handles (the round-7 drift bug). +/// Measurement is deliberately excluded -- it needs the registry + per-walker bookkeeping. +fn gate_op_to_cmd( + ctx: &Context, + op: Ptr, + qubit_of: &HashMap, +) -> Option { + let q = |i: usize, o: Ptr| qubit_of[&o.deref(ctx).get_operand(i)]; + if let Some(p) = Operation::get_op::(op, ctx) { + Some(Cmd::Pz(q(0, p.get_operation()))) + } else if let Some(h) = Operation::get_op::(op, ctx) { + Some(Cmd::H(q(0, h.get_operation()))) + } else if let Some(x) = Operation::get_op::(op, ctx) { + Some(Cmd::X(q(0, x.get_operation()))) + } else if let Some(cx) = Operation::get_op::(op, ctx) { + Some(Cmd::Cx(q(0, cx.get_operation()), q(1, cx.get_operation()))) + } else if let Some(cz) = Operation::get_op::(op, ctx) { + Some(Cmd::Cz(q(0, cz.get_operation()), q(1, cz.get_operation()))) + } else if let Some(sw) = Operation::get_op::(op, ctx) { + Some(Cmd::Swap( + q(0, sw.get_operation()), + q(1, sw.get_operation()), + )) + } else if let Some(r) = Operation::get_op::(op, ctx) { + Some(Cmd::Rz( + q(0, r.get_operation()), + get_angle(ctx, r.get_operation()), + )) + } else if let Some(r) = Operation::get_op::(op, ctx) { + Some(Cmd::Rx( + q(0, r.get_operation()), + get_angle(ctx, r.get_operation()), + )) + } else if let Some(r) = Operation::get_op::(op, ctx) { + Some(Cmd::Ry( + q(0, r.get_operation()), + get_angle(ctx, r.get_operation()), + )) + } else { + Operation::get_op::(op, ctx) + .map(|z| Cmd::Szz(q(0, z.get_operation()), q(1, z.get_operation()))) + } +} + +pub fn block_to_cmds( + ctx: &Context, + block: Ptr, + qubit_of: &HashMap, + reg: &MeasurementRegistry, +) -> Vec { + let mut cmds = Vec::new(); + for op in block.deref(ctx).iter(ctx).collect::>() { + if let Some(cmd) = gate_op_to_cmd(ctx, op, qubit_of) { + cmds.push(cmd); + } else if let Some(m) = Operation::get_op::(op, ctx) { + cmds.push(measure_to_mz(ctx, m, qubit_of, reg)); + } + } + cmds +} + +pub struct IfPlan { + batch1: Vec, + cond_outcome_idx: usize, + then_cmds: Vec, + else_cmds: Vec, + post: Vec, + export: Vec, // QIS result-ids in `qec.record` (result_record_output) order +} + +/// Walk the func block; split at the `qec.if` boundary, reading the two region bodies as the +/// then/else command lists. Measurement metadata + export order come from the registry. +pub fn plan_from_if_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegistry) -> IfPlan { + let mut qubit_of: HashMap = HashMap::new(); + let (mut batch1, mut post, mut then_cmds, mut else_cmds) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let mut after_if = false; + let mut mz_b1 = 0usize; + let mut cond_outcome_idx = 0usize; + let mut export: Vec = Vec::new(); + for op in block.deref(ctx).iter(ctx).collect::>() { + if let Some(s) = Operation::get_op::(op, ctx) { + qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); + } else if let Some(m) = Operation::get_op::(op, ctx) { + let mz = measure_to_mz(ctx, m, &qubit_of, reg); + if after_if { + post.push(mz); + } else { + cond_outcome_idx = mz_b1; + mz_b1 += 1; + batch1.push(mz); + } + } else if let Some(ifop) = Operation::get_op::(op, ctx) { + then_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 0), &qubit_of, reg); + else_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 1), &qubit_of, reg); + after_if = true; + } else if let Some(cmd) = gate_op_to_cmd(ctx, op, &qubit_of) { + if after_if { + post.push(cmd) + } else { + batch1.push(cmd) + } + } else if let Some(rec) = Operation::get_op::(op, ctx) { + // export order = textual order of qec.record; resolve the recorded measurement-SSA value + // to its export label via the registry (the value is the identity). + let recorded = rec.get_operation().deref(ctx).get_operand(0); + export.push(reg.get(recorded).export_label); + } + } + IfPlan { + batch1, + cond_outcome_idx, + then_cmds, + else_cmds, + post, + export, + } +} + +#[derive(Clone)] +pub struct PlironIfEngine { + batch1: Vec, + cond_outcome_idx: usize, + then_cmds: Vec, + else_cmds: Vec, + post: Vec, + export: Vec, // QIS result-ids to record, in result_record_output order + stage: u8, + b1: Vec, + b2: Vec, +} +impl PlironIfEngine { + fn b1_msg(&self) -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + emit_cmds(&mut b, &self.batch1); + b.build() + } + fn taken_cmds(&self) -> &[Cmd] { + if self.b1.get(self.cond_outcome_idx).copied() == Some(1) { + &self.then_cmds + } else { + &self.else_cmds + } + } + fn b2_msg(&self) -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + emit_cmds(&mut b, self.taken_cmds()); + emit_cmds(&mut b, &self.post); + b.build() + } + /// Reconstruct `result-id -> outcome` by replaying the emission order: batch1's `Mz`s consume + /// `b1` in order; batch2 (the runtime-taken branch, then `post`) consume `b2` in order. This is + /// the explicit measurement-SSA mapping -- each measurement's QIS result-id keyed to its value. + fn outcome_by_result_id(&self) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut i = 0; + for c in &self.batch1 { + if let Cmd::Mz(_, rid) = *c { + map.insert(rid, self.b1.get(i).copied().unwrap_or(0)); + i += 1; + } + } + let mut j = 0; + for c in self.taken_cmds().iter().chain(self.post.iter()) { + if let Cmd::Mz(_, rid) = *c { + map.insert(rid, self.b2.get(j).copied().unwrap_or(0)); + j += 1; + } + } + map + } +} +impl Engine for PlironIfEngine { + type Input = (); + type Output = Shot; + fn process(&mut self, _i: ()) -> std::result::Result { + self.get_results() + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + self.stage = 0; + self.b1.clear(); + self.b2.clear(); + Ok(()) + } +} +impl ClassicalEngine for PlironIfEngine { + fn num_qubits(&self) -> usize { + cmds_num_qubits(&[&self.batch1, &self.then_cmds, &self.else_cmds, &self.post]) + } + fn generate_commands(&mut self) -> std::result::Result { + if self.stage == 0 { + self.stage = 1; + Ok(self.b1_msg()) + } else { + Ok(ByteMessage::create_empty()) + } + } + fn handle_measurements(&mut self, m: ByteMessage) -> std::result::Result<(), PecosError> { + let o = m.outcomes()?; + if self.stage == 1 { + self.b1 = o + } else { + self.b2 = o + } + Ok(()) + } + fn get_results(&self) -> std::result::Result { + let map = self.outcome_by_result_id(); + let mut s = Shot::default(); + // one 1-bit register per recorded result-id ("r"), in result_record_output order. + for &rid in &self.export { + s.add_register(&format!("r{rid}"), map.get(&rid).copied().unwrap_or(0), 1); + } + Ok(s) + } + fn compile(&self) -> std::result::Result<(), PecosError> { + Ok(()) + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} +impl ControlEngine for PlironIfEngine { + type Input = (); + type Output = Shot; + type EngineInput = ByteMessage; + type EngineOutput = ByteMessage; + fn start(&mut self, _i: ()) -> std::result::Result, PecosError> { + self.stage = 1; + self.b1.clear(); + self.b2.clear(); + Ok(EngineStage::NeedsProcessing(self.b1_msg())) + } + fn continue_processing( + &mut self, + meas: ByteMessage, + ) -> std::result::Result, PecosError> { + if self.stage == 1 { + self.b1 = meas.outcomes()?; + self.stage = 2; + Ok(EngineStage::NeedsProcessing(self.b2_msg())) + } else { + self.b2 = meas.outcomes()?; + Ok(EngineStage::Complete(self.get_results()?)) + } + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } +} + +pub fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr, MeasurementRegistry) { + let module = ModuleOp::new(ctx, "adaptive_if".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; + } + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let s0 = push!(SlotOp::new(ctx, qv, 0)); + let s0v = s0.get_result(ctx); + let s1 = push!(SlotOp::new(ctx, qv, 1)); + let s1v = s1.get_result(ctx); + push!(PrepareOp::new(ctx, s0v)); + push!(PrepareOp::new(ctx, s1v)); + push!(HOp::new(ctx, s0v)); + let mut reg = MeasurementRegistry::default(); + let m0 = push!(MeasureOp::new(ctx, s0v)); // mid measure (conditioning) + let m0v = m0.get_result(ctx); + reg.record( + m0v, + MeasurementInfo { + qubit: 0, + basis: Basis::Z, + export_label: 0, + }, + ); + let ifop = push!(IfOp::new(ctx, m0v)); // if m0 { x q1 } else { } + let then_bb = ifop.make_region_block(ctx, 0); + XOp::new(ctx, s1v) + .get_operation() + .insert_at_back(then_bb, ctx); + let _else_bb = ifop.make_region_block(ctx, 1); + let m1 = push!(MeasureOp::new(ctx, s1v)); // final measure + let m1v = m1.get_result(ctx); + reg.record( + m1v, + MeasurementInfo { + qubit: 1, + basis: Basis::Z, + export_label: 1, + }, + ); + push!(RecordOp::new(ctx, m0v)); // record mid -> register r0 + push!(RecordOp::new(ctx, m1v)); // record final -> register r1 + push!(EndOp::new(ctx)); + (module, bb, reg) +} + +pub fn run_milestone_5() { + let ctx = &mut Context::new(); + let (module, bb, reg) = build_if_ir(ctx); + println!("=== region-based conditional (qec.if) pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-5 verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_if_ir(ctx, bb, ®); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + let (mut all_eq, mut saw0, mut saw1) = (true, false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + // explicit export mapping: r0 = mid (result-id 0), r1 = final (result-id 1) + let mid = shot + .data + .get("r0") + .and_then(Data::as_u32) + .expect("r0 (mid)"); + let fin = shot + .data + .get("r1") + .and_then(Data::as_u32) + .expect("r1 (final)"); + if fin != mid { + all_eq = false; + } + saw0 |= mid == 0; + saw1 |= mid == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!( + all_eq, + "milestone-5: final must equal mid (region-based qec.if feedback)" + ); + assert!(saw0 && saw1, "milestone-5: expected both mid=0 and mid=1"); + println!( + "[milestone-5 region-based qec.if via HybridEngine] OK -- r1(final)==r0(mid) in all 200 shots, saw mid 0 and 1" + ); +} + +// ===================== Milestone 6: parse the literal qprog.ll (adaptive) ===================== +// qprog.ll: rz/rx/ry/zz; mid-measure; icmp+br (diamond CFG); conditional x; final measures. +// We lift the diamond CFG into a `qec.if` during the parse, producing real rotations + region +// control flow, and run it end-to-end through the real HybridEngine. + +#[derive(Clone, Copy)] +pub enum ParsedOp { + H(usize), + Rz(usize, f64), + Rx(usize, f64), + Ry(usize, f64), + Szz(usize, usize), + Cz(usize, usize), + Swap(usize, usize), + X(usize), + M(usize, u64), // (qubit, QIS result-id = 2nd i64 of m__body) + Record(u64), // result_record_output(result-id): export this measurement-SSA, in this order +} + +pub fn inner_parens(l: &str) -> &str { + match (l.find('('), l.rfind(')')) { + (Some(a), Some(b)) if b > a => &l[a + 1..b], + _ => "", + } +} +pub fn doubles_and_ints(inner: &str) -> (Vec, Vec) { + let (mut ds, mut is) = (Vec::new(), Vec::new()); + for t in inner.split(',') { + let t = t.trim(); + if let Some(d) = t.strip_prefix("double ") + && let Ok(v) = d.trim().parse::() + { + ds.push(v); + } else if let Some(n) = t.strip_prefix("i64 ") + && let Ok(v) = n.trim().parse::() + { + is.push(v); + } + } + (ds, is) +} +pub fn br_labels(l: &str) -> Vec { + l.split("label %") + .skip(1) + .filter_map(|s| { + s.split([',', ' ']) + .next() + .filter(|x| !x.is_empty()) + .map(str::to_string) + }) + .collect() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QisLlShape { + StraightLine, + SingleDiamond, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LastQuantumOp { + Measure, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum LlTerminator { + Ret, + Br(String), + CondBr { + cond: String, + then_label: String, + else_label: String, + }, +} + +/// A parsed `%result = icmp i32 , ` -- enough to prove the branch condition's dataflow. +#[derive(Clone, Debug, PartialEq, Eq)] +struct IcmpShape { + result: String, // the `%name` the icmp defines + measure_ref: Option, // the `%ssa` operand (the measurement result it compares), if any + rhs_const: Option, // the integer-literal operand, if any + pred_eq: bool, // predicate is `eq` +} + +#[derive(Clone, Debug, Default)] +struct LlBlockShape { + label: String, + terminator: Option, + last_quantum_before_terminator: Option, + measures: Vec, // SSA result names of `m__body` calls, in order + icmp: Option, // the last `icmp` defined in the block +} + +/// `%name = ...` -> `Some("%name")` (the SSA value a line defines), else `None`. +fn ssa_lhs(l: &str) -> Option<&str> { + let (lhs, _rhs) = l.split_once('=')?; + let lhs = lhs.trim(); + lhs.starts_with('%').then_some(lhs) +} + +/// Parse the RHS of `%result = icmp eq i32 %mid, 1` into an [`IcmpShape`]. +fn parse_icmp(result: &str, rhs: &str) -> Option { + let rest = rhs.trim().strip_prefix("icmp ")?; // "eq i32 %mid, 1" + let mut it = rest.split_whitespace(); + let pred = it.next()?; // "eq" + let _ty = it.next()?; // "i32" + let operands = it.collect::>().join(" "); // "%mid, 1" + let (mut measure_ref, mut rhs_const) = (None, None); + for op in operands.split(',') { + let op = op.trim(); + if op.starts_with('%') { + measure_ref = Some(op.to_string()); + } else if let Ok(k) = op.parse::() { + rhs_const = Some(k); + } + } + Some(IcmpShape { + result: result.to_string(), + measure_ref, + rhs_const, + pred_eq: pred == "eq", + }) +} + +fn block_name(label: &str) -> &str { + if label.is_empty() { "entry" } else { label } +} + +fn collect_qmain_cfg(src: &str) -> std::result::Result, PecosError> { + let mut blocks = Vec::new(); + let mut cur = LlBlockShape::default(); + let mut in_func = false; + let mut saw_qmain = false; + for raw in src.lines() { + let l = raw.trim(); + if !in_func { + if l.starts_with("define ") && l.contains("@qmain") { + in_func = true; + saw_qmain = true; + cur = LlBlockShape::default(); + } + continue; + } + if l.is_empty() || l.starts_with(';') { + continue; + } + if l == "}" { + blocks.push(cur); + return Ok(blocks); + } + if l.ends_with(':') && !l.contains(' ') { + blocks.push(cur); + cur = LlBlockShape { + label: l.trim_end_matches(':').to_string(), + ..Default::default() + }; + continue; + } + if cur.terminator.is_some() { + return Err(unsupported_qis(format!( + "instructions after terminator in block {}", + block_name(&cur.label) + ))); + } + if l.starts_with("br ") { + let labels = br_labels(l); + cur.terminator = Some(if l.starts_with("br i1 ") { + if labels.len() != 2 { + return Err(unsupported_qis(format!( + "conditional branch must name two labels: {l}" + ))); + } + let cond = l + .strip_prefix("br i1 ") + .and_then(|r| r.split(',').next()) + .map(str::trim) + .unwrap_or_default() + .to_string(); + LlTerminator::CondBr { + cond, + then_label: labels[0].clone(), + else_label: labels[1].clone(), + } + } else if labels.len() == 1 { + LlTerminator::Br(labels[0].clone()) + } else { + return Err(unsupported_qis(format!("unsupported branch form: {l}"))); + }); + } else if l.starts_with("ret ") { + cur.terminator = Some(LlTerminator::Ret); + } else if l.contains("__quantum__qis__m__body") { + cur.last_quantum_before_terminator = Some(LastQuantumOp::Measure); + if let Some(name) = ssa_lhs(l) { + cur.measures.push(name.to_string()); // record the measurement-result SSA name + } + } else if l.contains("__quantum__") { + cur.last_quantum_before_terminator = Some(LastQuantumOp::Other); + } else if let Some(lhs) = ssa_lhs(l) + && let Some((_, rhs)) = l.split_once('=') + && rhs.trim().starts_with("icmp ") + { + cur.icmp = parse_icmp(lhs, rhs.trim()); // the branch-condition icmp + } + } + if saw_qmain { + Err(unsupported_qis("qmain function missing closing brace")) + } else { + Err(unsupported_qis("missing @qmain function")) + } +} + +fn validate_straight_line_blocks(blocks: &[LlBlockShape]) -> std::result::Result<(), PecosError> { + if blocks.len() != 1 || blocks.first().is_some_and(|b| !b.label.is_empty()) { + return Err(unsupported_qis( + "straight-line subset must have exactly one basic block; control-flow labels/branches are unsupported", + )); + } + match blocks.first().and_then(|b| b.terminator.as_ref()) { + Some(LlTerminator::Br(_)) | Some(LlTerminator::CondBr { .. }) => Err(unsupported_qis( + "straight-line subset cannot contain branch control flow", + )), + Some(LlTerminator::Ret) | None => Ok(()), + } +} + +fn validate_straight_line_qis_shape(src: &str) -> std::result::Result<(), PecosError> { + let blocks = collect_qmain_cfg(src)?; + validate_straight_line_blocks(&blocks) +} + +fn single_diamond_branch_target(block: &LlBlockShape) -> std::result::Result<&str, PecosError> { + match &block.terminator { + Some(LlTerminator::Br(target)) => Ok(target.as_str()), + _ => Err(unsupported_qis(format!( + "then/else block {} must end with an unconditional branch to the common merge block", + block_name(&block.label) + ))), + } +} + +fn validate_single_diamond_blocks(blocks: &[LlBlockShape]) -> std::result::Result<(), PecosError> { + let cond_blocks: Vec<&LlBlockShape> = blocks + .iter() + .filter(|b| matches!(&b.terminator, Some(LlTerminator::CondBr { .. }))) + .collect(); + if cond_blocks.len() > 1 { + return Err(unsupported_qis( + "more than one conditional branch -- only a single diamond is supported", + )); + } + if cond_blocks.is_empty() { + return Err(unsupported_qis( + "single-diamond subset requires one conditional branch", + )); + } + + let entry = blocks + .iter() + .find(|b| b.label.is_empty()) + .ok_or_else(|| unsupported_qis("missing entry block"))?; + if !cond_blocks[0].label.is_empty() { + return Err(unsupported_qis( + "single-diamond conditional branch must be in the entry block", + )); + } + if entry.last_quantum_before_terminator != Some(LastQuantumOp::Measure) { + return Err(unsupported_qis( + "entry conditional branch must follow a mid-measurement", + )); + } + + let (cond, then_label, else_label) = match &entry.terminator { + Some(LlTerminator::CondBr { + cond, + then_label, + else_label, + }) => (cond, then_label, else_label), + _ => { + return Err(unsupported_qis( + "entry block must terminate with the single conditional branch", + )); + } + }; + if then_label == else_label { + return Err(unsupported_qis( + "conditional branch arms must target distinct blocks", + )); + } + + // Branch-condition dataflow: prove the branch is driven by the trailing mid-measurement, i.e. + // %mid = qis.m ... ; %c = icmp eq i32 %mid, 1 ; br i1 %c, ... + // The lowering treats the entry's last measure as the `qec.if` condition and the then-arm as the + // `mid==1` case, so we require exactly that: the branch uses an `icmp eq , 1`. + // (Without this, a constant condition like `icmp eq i32 0, 1` or a branch on an *earlier* + // measurement would pass the shape check and be silently mis-lowered.) + let icmp = entry.icmp.as_ref().ok_or_else(|| { + unsupported_qis( + "conditional branch condition must be produced by an `icmp` on the mid-measurement", + ) + })?; + if icmp.result != *cond { + return Err(unsupported_qis( + "conditional branch does not use the entry `icmp` result", + )); + } + if !icmp.pred_eq || icmp.rhs_const != Some(1) { + return Err(unsupported_qis( + "only `icmp eq , 1` branch conditions are supported", + )); + } + let measure_ref = icmp.measure_ref.as_deref().ok_or_else(|| { + unsupported_qis("branch condition must compare a measurement result, not a constant") + })?; + let last_measure = + entry.measures.last().map(String::as_str).ok_or_else(|| { + unsupported_qis("entry block has no named measurement to condition on") + })?; + if measure_ref != last_measure { + return Err(unsupported_qis( + "conditional branch must condition on the trailing mid-measurement, not an earlier one", + )); + } + + let mut by_label: HashMap<&str, &LlBlockShape> = HashMap::new(); + for block in blocks { + if by_label.insert(block.label.as_str(), block).is_some() { + return Err(unsupported_qis(format!( + "duplicate basic-block label {}", + block_name(&block.label) + ))); + } + } + + let then_block = by_label.get(then_label.as_str()).copied().ok_or_else(|| { + unsupported_qis(format!("conditional branch target {then_label} is missing")) + })?; + let else_block = by_label.get(else_label.as_str()).copied().ok_or_else(|| { + unsupported_qis(format!("conditional branch target {else_label} is missing")) + })?; + + let then_merge = single_diamond_branch_target(then_block)?; + let else_merge = single_diamond_branch_target(else_block)?; + if then_merge != else_merge { + return Err(unsupported_qis( + "then/else blocks must branch to the same merge block", + )); + } + if then_merge.is_empty() || then_merge == then_label || then_merge == else_label { + return Err(unsupported_qis( + "single-diamond merge target must be a distinct non-entry block", + )); + } + + let merge_block = by_label + .get(then_merge) + .copied() + .ok_or_else(|| unsupported_qis(format!("merge block {then_merge} is missing")))?; + if !matches!(merge_block.terminator, Some(LlTerminator::Ret) | None) { + return Err(unsupported_qis( + "single-diamond merge block must not branch again", + )); + } + + for block in blocks { + if !block.label.is_empty() + && block.label != *then_label + && block.label != *else_label + && block.label != then_merge + { + return Err(unsupported_qis(format!( + "extra basic block {} outside the single-diamond shape", + block_name(&block.label) + ))); + } + } + Ok(()) +} + +fn validate_single_diamond_qis_shape(src: &str) -> std::result::Result<(), PecosError> { + let blocks = collect_qmain_cfg(src)?; + validate_single_diamond_blocks(&blocks) +} + +fn classify_covered_qis_shape(src: &str) -> std::result::Result { + let blocks = collect_qmain_cfg(src)?; + let conditional_branches = blocks + .iter() + .filter(|b| matches!(&b.terminator, Some(LlTerminator::CondBr { .. }))) + .count(); + if conditional_branches == 0 { + validate_straight_line_blocks(&blocks)?; + Ok(QisLlShape::StraightLine) + } else { + validate_single_diamond_blocks(&blocks)?; + Ok(QisLlShape::SingleDiamond) + } +} +pub fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { + for p in ops { + match *p { + ParsedOp::H(q) + | ParsedOp::Rz(q, _) + | ParsedOp::Rx(q, _) + | ParsedOp::Ry(q, _) + | ParsedOp::X(q) + | ParsedOp::M(q, _) => { + set.insert(q); + } + ParsedOp::Szz(a, b) | ParsedOp::Cz(a, b) | ParsedOp::Swap(a, b) => { + set.insert(a); + set.insert(b); + } + ParsedOp::Record(_) => {} + } + } +} +/// Emit one parsed op into `block`. `measured` accumulates `result-id -> measurement-SSA Value` so a +/// later `ParsedOp::Record` resolves exactly which `qec.measure` becomes a program output; `reg` +/// gets each measurement's metadata (qubit, basis, export label) keyed by its SSA value. +pub fn emit_parsed( + ctx: &mut Context, + p: &ParsedOp, + block: Ptr, + slot_of: &HashMap, + measured: &mut HashMap, + reg: &mut MeasurementRegistry, +) -> std::result::Result<(), PecosError> { + match *p { + ParsedOp::H(q) => { + HOp::new(ctx, slot_of[&q]) + .get_operation() + .insert_at_back(block, ctx); + } + // .ll angles are f64 radians (the wire format); convert to fixed-point Angle64 at the boundary. + ParsedOp::Rz(q, t) => { + RzOp::new(ctx, slot_of[&q], Angle64::from_radians(t)) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Rx(q, t) => { + RxOp::new(ctx, slot_of[&q], Angle64::from_radians(t)) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Ry(q, t) => { + RyOp::new(ctx, slot_of[&q], Angle64::from_radians(t)) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Szz(a, b) => { + SzzOp::new(ctx, slot_of[&a], slot_of[&b]) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Cz(a, b) => { + CzOp::new(ctx, slot_of[&a], slot_of[&b]) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Swap(a, b) => { + SwapOp::new(ctx, slot_of[&a], slot_of[&b]) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::X(q) => { + XOp::new(ctx, slot_of[&q]) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::M(q, rid) => { + let m = MeasureOp::new(ctx, slot_of[&q]); + m.get_operation().insert_at_back(block, ctx); + let v = m.get_result(ctx); + measured.insert(rid, v); + reg.record( + v, + MeasurementInfo { + qubit: q, + basis: Basis::Z, + export_label: rid, + }, + ); + } + ParsedOp::Record(rid) => { + let v = *measured.get(&rid).ok_or_else(|| { + unsupported_qis(format!( + "result_record_output references unknown result-id {rid}" + )) + })?; + RecordOp::new(ctx, v) + .get_operation() + .insert_at_back(block, ctx); + } + } + Ok(()) +} + +/// Parse the adaptive single-diamond QIS-LLVM subset, lifting the conditional branch into a `qec.if`. +/// Rejects (structured error, not silent-drop/panic) anything outside the subset: unrecognized +/// `__quantum__` calls, malformed operand lists, and more than one conditional branch. +pub fn parse_qprog_ll( + ctx: &mut Context, + src: &str, +) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { + validate_single_diamond_qis_shape(src)?; + + // pass 1: collect ops per block label (entry = "") and the conditional-branch targets. + let mut blocks: Vec<(String, Vec)> = Vec::new(); + let mut cur_label = String::new(); + let mut cur_ops: Vec = Vec::new(); + let (mut then_label, mut else_label) = (None::, None::); + let mut in_func = false; + for raw in src.lines() { + let l = raw.trim(); + if l.starts_with("define ") && l.contains("@qmain") { + in_func = true; + continue; + } + if !in_func { + continue; + } + if l == "}" { + blocks.push((std::mem::take(&mut cur_label), std::mem::take(&mut cur_ops))); + break; + } + if l.ends_with(':') && !l.contains(' ') { + blocks.push((std::mem::take(&mut cur_label), std::mem::take(&mut cur_ops))); + cur_label = l.trim_end_matches(':').to_string(); + continue; + } + let (ds, is) = doubles_and_ints(inner_parens(l)); + let iq = |i: usize| { + is.get(i) + .copied() + .ok_or_else(|| unsupported_qis(format!("missing i64 operand {i}: {l}"))) + }; + let da = |i: usize| { + ds.get(i) + .copied() + .ok_or_else(|| unsupported_qis(format!("missing double operand {i}: {l}"))) + }; + if l.contains("__quantum__qis__h__body") { + cur_ops.push(ParsedOp::H(iq(0)?)); + } else if l.contains("__quantum__qis__rz__body") { + cur_ops.push(ParsedOp::Rz(iq(0)?, da(0)?)); + } else if l.contains("__quantum__qis__rx__body") { + cur_ops.push(ParsedOp::Rx(iq(0)?, da(0)?)); + } else if l.contains("__quantum__qis__ry__body") { + cur_ops.push(ParsedOp::Ry(iq(0)?, da(0)?)); + } else if l.contains("__quantum__qis__zz__body") { + cur_ops.push(ParsedOp::Szz(iq(0)?, iq(1)?)); + } else if l.contains("__quantum__qis__cz__body") { + cur_ops.push(ParsedOp::Cz(iq(0)?, iq(1)?)); + } else if l.contains("__quantum__qis__swap__body") { + cur_ops.push(ParsedOp::Swap(iq(0)?, iq(1)?)); + } else if l.contains("__quantum__qis__x__body") { + cur_ops.push(ParsedOp::X(iq(0)?)); + } else if l.contains("__quantum__qis__m__body") { + cur_ops.push(ParsedOp::M(iq(0)?, iq(1)? as u64)); // m__body(i64 qubit, i64 result_id) + } else if l.contains("__quantum__rt__result_record_output") { + cur_ops.push(ParsedOp::Record(iq(0)? as u64)); // result_record_output(i64 result_id, i8* null) + } else if l.starts_with("br ") && l.contains("label %") { + let labels = br_labels(l); + if labels.len() == 2 { + if then_label.is_some() { + return Err(unsupported_qis( + "more than one conditional branch -- only a single diamond is supported", + )); + } + then_label = Some(labels[0].clone()); + else_label = Some(labels[1].clone()); + } + } else if l.contains("__quantum__") { + return Err(unsupported_qis(format!( + "operation not in the covered subset: {l}" + ))); + } + } + let find = |lab: &str| { + blocks + .iter() + .find(|(l, _)| l == lab) + .map(|(_, o)| o.clone()) + .unwrap_or_default() + }; + let entry_ops = find(""); + let then_ops = then_label.as_deref().map(find).unwrap_or_default(); + let else_ops = else_label.as_deref().map(find).unwrap_or_default(); + let merge_ops = blocks + .iter() + .find(|(l, _)| { + !l.is_empty() && Some(l) != then_label.as_ref() && Some(l) != else_label.as_ref() + }) + .map(|(_, o)| o.clone()) + .unwrap_or_default(); + + // pass 2: build the pliron qec IR. + let mut qubits = BTreeSet::new(); + for ops in [&entry_ops, &then_ops, &else_ops, &merge_ops] { + collect_qubits(ops, &mut qubits); + } + + let module = ModuleOp::new(ctx, "qprog".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "qmain".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + + let q = QallocOp::new(ctx); + q.get_operation().insert_at_back(bb, ctx); + let qv = q.get_result(ctx); + let mut slot_of: HashMap = HashMap::new(); + for &idx in &qubits { + let s = SlotOp::new(ctx, qv, idx as u64); + s.get_operation().insert_at_back(bb, ctx); + let sv = s.get_result(ctx); + slot_of.insert(idx, sv); + PrepareOp::new(ctx, sv) + .get_operation() + .insert_at_back(bb, ctx); + } + // result-id -> measurement-SSA Value, so result_record_output can name the recorded measurement. + let mut measured: HashMap = HashMap::new(); + let mut reg = MeasurementRegistry::default(); + // entry gates (everything before the trailing mid-measure), then the mid measure (the cond). + let (mid_q, mid_rid) = match entry_ops.last() { + Some(ParsedOp::M(q, rid)) => (*q, *rid), + _ => { + return Err(unsupported_qis( + "entry block must end with a mid-measurement (single-diamond adaptive shape)", + )); + } + }; + for p in &entry_ops[..entry_ops.len() - 1] { + emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg)?; + } + let m0 = MeasureOp::new(ctx, slot_of[&mid_q]); + m0.get_operation().insert_at_back(bb, ctx); + let m0v = m0.get_result(ctx); + measured.insert(mid_rid, m0v); + reg.record( + m0v, + MeasurementInfo { + qubit: mid_q, + basis: Basis::Z, + export_label: mid_rid, + }, + ); + // lift the diamond into qec.if(mid) { then } { else } + let ifop = IfOp::new(ctx, m0v); + ifop.get_operation().insert_at_back(bb, ctx); + let then_bb = ifop.make_region_block(ctx, 0); + for p in &then_ops { + emit_parsed(ctx, p, then_bb, &slot_of, &mut measured, &mut reg)?; + } + let else_bb = ifop.make_region_block(ctx, 1); + for p in &else_ops { + emit_parsed(ctx, p, else_bb, &slot_of, &mut measured, &mut reg)?; + } + // final measurements + result_record_output ops (the export list) + for p in &merge_ops { + emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg)?; + } + EndOp::new(ctx).get_operation().insert_at_back(bb, ctx); + Ok((module, bb, reg)) +} + +pub fn run_milestone_6() { + let ctx = &mut Context::new(); + let src = include_str!("../../../examples/llvm/qprog.ll"); + let (module, bb, reg) = parse_qprog_ll(ctx, src).expect("milestone-6: parse qprog.ll"); + println!("=== qprog.ll parsed into pliron qec IR (rotations + qec.if) ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-6 verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_if_ir(ctx, bb, ®); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + // qprog records result-ids 0,1,2 -> registers r0=final_q0, r1=final_q1, r2=mid (export order). + let mut seen: BTreeSet<(u32, u32, u32)> = BTreeSet::new(); + let mut n = 0; + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let f0 = shot + .data + .get("r0") + .and_then(Data::as_u32) + .expect("r0 (final_q0)"); + let f1 = shot + .data + .get("r1") + .and_then(Data::as_u32) + .expect("r1 (final_q1)"); + let mid = shot + .data + .get("r2") + .and_then(Data::as_u32) + .expect("r2 (mid)"); + // q0 only sees Z-diagonal ops (rz, szz), so its mid and final measures are deterministically 0. + assert_eq!(mid, 0, "milestone-6: q0 is Z-diagonal, mid (r2) must be 0"); + assert_eq!( + f0, 0, + "milestone-6: q0 is Z-diagonal, final_q0 (r0) must be 0" + ); + seen.insert((mid, f0, f1)); + n += 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert_eq!(n, 200, "milestone-6: expected 200 shots"); + assert!( + !seen.is_empty(), + "milestone-6: qprog.ll must produce results" + ); + // The conditional branch is never taken here (mid is deterministically 0) -- branch-firing is + // exercised by M5/M7; the quantum variety is on q1 (rx(pi)+ry+szz), so r1 varies across shots. + println!( + "[milestone-6 qprog.ll -> pliron qec (rotations + qec.if) -> HybridEngine] OK -- {n} shots, observed (r2_mid,r0_final_q0,r1_final_q1): {seen:?}" + ); +} + +/// M7 step 2: a branch-*taken* fixture parsed from `.ll` — proves `qec.if` firing from parsed input +/// (unlike qprog.ll, whose q0 is deterministic). h q0 -> measure -> if 1 { x q1 } -> measure q1. +pub fn run_milestone_7() { + let ctx = &mut Context::new(); + let src = include_str!("../fixtures/adaptive_branch.ll"); + let (module, bb, reg) = + parse_qprog_ll(ctx, src).expect("milestone-7: parse adaptive_branch.ll"); + println!("=== adaptive_branch.ll parsed into pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-7 verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_if_ir(ctx, bb, ®); + assert_eq!( + plan.export, + vec![2, 1], + "milestone-7: result_record_output order must be [2,1] (mid, final_q1)" + ); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + // adaptive_branch records result-ids 2,1 -> r2 = mid (q0 after h), r1 = final_q1. Invariant r1==r2. + let (mut all_eq, mut saw0, mut saw1) = (true, false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let mid = shot + .data + .get("r2") + .and_then(Data::as_u32) + .expect("r2 (mid)"); + let fin = shot + .data + .get("r1") + .and_then(Data::as_u32) + .expect("r1 (final_q1)"); + if fin != mid { + all_eq = false; + } + saw0 |= mid == 0; + saw1 |= mid == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!( + all_eq, + "milestone-7: final_q1 (r1) must equal mid (r2) -- qec.if firing from parsed input" + ); + assert!( + saw0 && saw1, + "milestone-7: branch must both fire (mid=1) and not (mid=0)" + ); + println!( + "[milestone-7 adaptive_branch.ll -> qec.if branch TAKEN from parsed input] OK -- r1(final_q1)==r2(mid) all 200 shots, mid 0 and 1 both seen" + ); +} + +/// Coverage for a measurement INSIDE the conditional branch: `b2`'s length varies with the taken +/// branch (taken: `[m1, f0, f1]`; skipped: `[f0, f1]`), so the engine's outcome reconstruction must +/// walk the actually-emitted measures, not a fixed layout. The in-branch measure is not recorded +/// (a cross-region SSA escape needs yield -- Phase 2); recorded outputs are the unconditional finals. +pub fn run_branch_measure() { + let ctx = &mut Context::new(); + let src = include_str!("../fixtures/branch_measure.ll"); + let (module, bb, reg) = parse_qprog_ll(ctx, src).expect("branch_measure: parse"); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[branch_measure verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_if_ir(ctx, bb, ®); + // structural: the then-branch really does contain a measurement (this is what we are covering), + // and the in-branch measure (result-id 3) is NOT in the export list. + assert!( + plan.then_cmds.iter().any(|c| matches!(c, Cmd::Mz(..))), + "then-branch must contain a measurement" + ); + assert_eq!( + plan.export, + vec![0, 1], + "only the unconditional finals are recorded (in-branch m1 is not)" + ); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + let (mut all_eq, mut saw0, mut saw1) = (true, false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + // r0 = final_q0 (== mid), r1 = final_q1 (== mid). The in-branch measure of q1 leaves q1 == mid. + let r0 = shot + .data + .get("r0") + .and_then(Data::as_u32) + .expect("r0 (final_q0)"); + let r1 = shot + .data + .get("r1") + .and_then(Data::as_u32) + .expect("r1 (final_q1)"); + if r0 != r1 { + all_eq = false; + } + saw0 |= r0 == 0; + saw1 |= r0 == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!( + all_eq, + "branch_measure: final_q0 (r0) must equal final_q1 (r1) -- variable-length b2 reconstructed correctly" + ); + assert!( + saw0 && saw1, + "branch_measure: branch must both fire and not (r0 both 0 and 1)" + ); + println!( + "[branch_measure measure-inside-branch -> variable-length b2 reconstruction] OK -- r0==r1 all 200 shots, both 0 and 1 seen" + ); +} + +// ===================== public adapter: the opt-in QIS-LLVM-IR -> pliron call path ===================== + +/// Lower a QIS-LLVM-IR program to the pliron `qec` dialect and return a boxed +/// `ClassicalControlEngine` ready for `HybridEngineBuilder` -- the narrow, opt-in entry point for the +/// pliron path. The incumbent `pecos-phir` stays the default; callers select this explicitly. +/// +/// Scope: the covered QIS-LLVM subset -- Bell-style straight-line and the single-diamond adaptive +/// shape (`h`/`x`/`cx`/`rz`/`rx`/`ry`/`zz`/`m`, one `icmp`+`br` lifted to `qec.if`, +/// `result_record_output` export). The returned engine reports its own `num_qubits()` for sizing the +/// quantum backend. Returns a structured error if the source is outside the covered subset or the +/// lowered IR fails verification. +pub fn from_qis_llvm_ir_pliron( + src: &str, +) -> std::result::Result, PecosError> { + let ctx = &mut Context::new(); + let (module, bb, reg) = match classify_covered_qis_shape(src)? { + QisLlShape::SingleDiamond => parse_qprog_ll(ctx, src)?, + QisLlShape::StraightLine => parse_bell_ll(ctx, src)?, + }; + verify_op(&module, ctx).map_err(|e| { + PecosError::Compilation(format!("pliron qec verification failed: {}", e.disp(ctx))) + })?; + let plan = plan_from_if_ir(ctx, bb, ®); + Ok(Box::new(PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + })) +} + +// ===================== regression tests (the milestones, run via `cargo test`) ===================== +#[cfg(test)] +mod tests { + use super::*; + + // ---- strangler differential vs the existing pecos-phir QIS->PHIR->sim path ---- + // Not an equivalence proof: on these fixtures pecos-phir produces nothing / errors, so the + // honest result is a *characterization* of where the pliron port supersedes the murky path. + // These assert pecos-phir's CURRENT behavior so a future change there trips the test and we + // re-examine the cutover criterion. + + /// bell.ll export divergence: the port lowers `result_record_output` to `qec.record` ops and + /// exports `r{id}` registers through the measurement registry (here exercising that real export + /// path, not a manual pack); pecos-phir *elides* `result_record_output` (and bell.ll has no other + /// export source), so its `Shot` is empty. + #[test] + fn differential_bell_ll_export_convention() { + let bell = include_str!("../../../examples/llvm/bell.ll"); + + // port side: bell.ll -> pliron qec (incl. qec.record + registry) -> the SAME export path as + // qprog (plan_from_if_ir + PlironIfEngine) -> r0/r1 registers. No IfOp -> a single batch. + let ctx = &mut Context::new(); + let (module, bb, reg) = parse_bell_ll(ctx, bell).expect("port lowers bell.ll"); + verify_op(&module, ctx).expect("port lowers bell.ll (with qec.record) and verifies"); + let plan = plan_from_if_ir(ctx, bb, ®); + assert_eq!( + plan.export, + vec![0, 1], + "bell.ll records result-ids 0 (q0) then 1 (q1)" + ); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = pecos_engines::hybrid::HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(2, 7))) + .build(); + let (mut saw0, mut saw1) = (false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (q0)"); + let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (q1)"); + assert_eq!( + r0, r1, + "port bell.ll via qec.record export must be Bell-correlated, got r0={r0} r1={r1}" + ); + saw0 |= r0 == 0; + saw1 |= r0 == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(saw0 && saw1, "port bell.ll must see both 00 and 11"); + + // pecos-phir side: same bell.ll, run through its real engine -> empty Shot (no exports). + let module = pecos_phir::parse_qis_to_quantum(bell).expect("pecos-phir parses bell.ll"); + let pe = pecos_phir::PhirEngine::new(module).expect("pecos-phir builds an engine"); + let mut hybrid = pecos_engines::hybrid::HybridEngineBuilder::new() + .with_classical_engine(Box::new(pe)) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(2, 7))) + .build(); + let shot = hybrid.run_shot().unwrap(); + assert!( + shot.data.is_empty(), + "DIVERGENCE: pecos-phir elides result_record_output, so bell.ll yields no exported \ + registers; the port exports r0/r1. pecos-phir gave: {:?}", + shot.data.keys().collect::>() + ); + println!( + "[differential bell.ll] port -> r0==r1 Bell pair via qec.record export; pecos-phir -> empty Shot (record_output elided)" + ); + } + + /// qprog.ll rotation divergence: the port lowers `rz/rx/ry/zz` (M6 runs it end-to-end), while + /// pecos-phir's `qis_to_quantum` currently cannot resolve the `rz` angle to a constant and errors. + #[test] + fn differential_qprog_ll_rotation_support() { + let qprog = include_str!("../../../examples/llvm/qprog.ll"); + + // port side: lowers without error (full end-to-end physics is m6). + let ctx = &mut Context::new(); + let (module, _bb, _reg) = parse_qprog_ll(ctx, qprog).expect("port lowers qprog.ll"); + verify_op(&module, ctx).expect("port lowers qprog.ll (rotations + qec.if) and verifies"); + + // pecos-phir side: errors lowering the rotation angle. + let err = pecos_phir::parse_qis_to_quantum(qprog) + .expect_err("DIVERGENCE: pecos-phir is expected to fail lowering qprog.ll's rotations"); + let msg = format!("{err:?}"); + assert!( + msg.contains("angle") || msg.contains("rz"), + "expected the divergence to be the rz-angle resolution; got: {msg}" + ); + println!( + "[differential qprog.ll] port lowers rotations + qec.if; pecos-phir errors: {msg}" + ); + } + + /// The real call path: drive the PUBLIC `from_qis_llvm_ir_pliron` adapter (not the internal + /// parse/plan/engine pieces) through `HybridEngine`, for both the straight-line (bell) and + /// diamond (qprog) shapes -- proving the opt-in entry point produces a usable engine. + #[test] + fn adapter_real_call_path_bell_and_qprog() { + use pecos_engines::hybrid::HybridEngineBuilder; + + // bell.ll (straight-line) -> r0==r1 Bell pair via the registry/qec.record export. + let eng = from_qis_llvm_ir_pliron(include_str!("../../../examples/llvm/bell.ll")) + .expect("adapter lowers bell.ll"); + let n = eng.num_qubits(); + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(eng) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(n, 11))) + .build(); + let (mut saw0, mut saw1) = (false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0"); + let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1"); + assert_eq!( + r0, r1, + "bell via adapter must be Bell-correlated, got r0={r0} r1={r1}" + ); + saw0 |= r0 == 0; + saw1 |= r0 == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(saw0 && saw1, "bell via adapter must see both 00 and 11"); + + // qprog.ll (diamond) -> records r0/r1/r2; q0 is Z-diagonal so mid (r2) and final_q0 (r0) are 0. + let eng = from_qis_llvm_ir_pliron(include_str!("../../../examples/llvm/qprog.ll")) + .expect("adapter lowers qprog.ll"); + let n = eng.num_qubits(); + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(eng) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(n, 11))) + .build(); + for _ in 0..50 { + let shot = hybrid.run_shot().unwrap(); + assert_eq!( + shot.data.get("r2").and_then(Data::as_u32), + Some(0), + "qprog mid (r2) deterministically 0" + ); + assert_eq!( + shot.data.get("r0").and_then(Data::as_u32), + Some(0), + "qprog final_q0 (r0) deterministically 0" + ); + assert!( + shot.data.get("r1").and_then(Data::as_u32).is_some(), + "qprog final_q1 (r1) present" + ); + Engine::reset(&mut hybrid).unwrap(); + } + } + /// Dynamic qubit count: a 3-qubit GHZ through the adapter must report `num_qubits()==3` (not the + /// old hard-coded 2) and produce a perfectly correlated triple (r0==r1==r2). + /// Widened gate set: `cz` + `swap` (+ `x`) lower and run through the adapter with a deterministic + /// result (`r0=1, r1=0, r2=1`). + #[test] + fn adapter_cz_swap_gates() { + use pecos_engines::hybrid::HybridEngineBuilder; + let eng = from_qis_llvm_ir_pliron(include_str!("../fixtures/cz_swap.ll")) + .expect("adapter lowers cz_swap.ll"); + let n = eng.num_qubits(); + assert_eq!(n, 3, "cz_swap uses qubits 0,1,2"); + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(eng) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(n, 5))) + .build(); + for _ in 0..50 { + let shot = hybrid.run_shot().unwrap(); + assert_eq!( + shot.data.get("r0").and_then(Data::as_u32), + Some(1), + "cz+h must drive q0 to 1" + ); + assert_eq!( + shot.data.get("r1").and_then(Data::as_u32), + Some(0), + "swap moves the 1 off q1" + ); + assert_eq!( + shot.data.get("r2").and_then(Data::as_u32), + Some(1), + "swap moves the 1 onto q2" + ); + Engine::reset(&mut hybrid).unwrap(); + } + } + #[test] + fn adapter_ghz3_dynamic_qubit_count() { + use pecos_engines::hybrid::HybridEngineBuilder; + let eng = from_qis_llvm_ir_pliron(include_str!("../fixtures/ghz3.ll")) + .expect("adapter lowers ghz3.ll"); + assert_eq!( + eng.num_qubits(), + 3, + "GHZ-3 engine must report 3 qubits (dynamic, not hard-coded 2)" + ); + let n = eng.num_qubits(); + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(eng) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(n, 13))) + .build(); + let (mut saw0, mut saw1) = (false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0"); + let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1"); + let r2 = shot.data.get("r2").and_then(Data::as_u32).expect("r2"); + assert!( + r0 == r1 && r1 == r2, + "GHZ-3 must be fully correlated, got r0={r0} r1={r1} r2={r2}" + ); + saw0 |= r0 == 0; + saw1 |= r0 == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(saw0 && saw1, "GHZ-3 must see both 000 and 111"); + } + #[test] + fn m0_hand_built_bell_seam() { + run_and_check("milestone-0 hand-built Bell", bell_message(), 200); + } + #[test] + fn m1_pliron_emitted_bell() { + run_milestone_1(); + } + #[test] + fn m2_pliron_classical_control_engine() { + run_milestone_2(); + } + #[test] + fn m3_bell_ll_parse() { + run_milestone_3(); + } + #[test] + fn m4_adaptive_multi_batch() { + run_milestone_4(); + } + #[test] + fn m5_region_based_qec_if() { + run_milestone_5(); + } + #[test] + fn m6_literal_qprog_ll() { + run_milestone_6(); + } + #[test] + fn m7_branch_taken_qec_if() { + run_milestone_7(); + } + #[test] + fn measure_inside_branch_variable_length_b2() { + run_branch_measure(); + } + + /// The measurement-SSA registry is the metadata home: every `qec.measure` value resolves to its + /// (qubit, basis, export-label) via the side-table, with no attribute on the op. Keyed by the + /// SSA value -- the value IS the identity. + #[test] + fn registry_holds_measurement_metadata() { + let ctx = &mut Context::new(); + let src = include_str!("../../../examples/llvm/qprog.ll"); + let (_module, bb, reg) = parse_qprog_ll(ctx, src).expect("parse qprog.ll"); + let mut infos: Vec<(usize, Basis, u64)> = bb + .deref(ctx) + .iter(ctx) + .collect::>() + .into_iter() + .filter_map(|op| Operation::get_op::(op, ctx)) + .map(|m| { + let i = reg.get(m.get_result(ctx)); + (i.qubit, i.basis, i.export_label) + }) + .collect(); + infos.sort_by_key(|&(q, _, label)| (label, q)); + // qprog.ll: result-id 0 = final q0, 1 = final q1, 2 = mid q0 -- all Z measurements. + assert_eq!( + infos, + vec![(0, Basis::Z, 0), (1, Basis::Z, 1), (0, Basis::Z, 2)] + ); + } + + // ---- negative tests: prove the verifiers and seam invariants actually bite ---- + + /// `qec.h` on a `qec.alloc` handle (an alloc, not a qubitref) must be rejected by verification. + /// Guards against the gate verifiers silently regressing to `verifier="succ"` no-ops. + #[test] + fn negative_h_on_alloc_rejected() { + let ctx = &mut Context::new(); + let alloc = QallocOp::new(ctx); + let bad = HOp::new(ctx, alloc.get_result(ctx)); + let res = verify_op(&bad, ctx); + assert!( + res.is_err(), + "qec.h on a qec.alloc handle must fail verification, got Ok" + ); + } + + /// `qec.cond_x`'s condition (operand 0) must be an `i1` measurement result. A non-`i1` condition + /// (here a qubitref) must be rejected -- guards the read-only type-inspection check in verify. + #[test] + fn negative_cond_x_non_i1_condition_rejected() { + let ctx = &mut Context::new(); + let alloc = QallocOp::new(ctx); + let s0 = SlotOp::new(ctx, alloc.get_result(ctx), 0); + let s1 = SlotOp::new(ctx, alloc.get_result(ctx), 1); + // use a qubitref (operand 0) as the condition -- it is not an i1 measurement result. + let bad = CondXOp::new(ctx, s0.get_result(ctx), s1.get_result(ctx)); + let err = verify_op(&bad, ctx) + .expect_err("qec.cond_x with a non-i1 condition must fail verification"); + assert!( + format!("{}", err.disp(ctx)).contains("i1"), + "expected the i1-condition rejection, got: {}", + err.disp(ctx) + ); + } + + /// `qec.rz` (and rx/ry) must reject a non-qubitref operand -- guards the rotation verifiers that + /// were tightened from `verifier = "succ"`. + #[test] + fn negative_rz_on_alloc_rejected() { + let ctx = &mut Context::new(); + let alloc = QallocOp::new(ctx); + // rz on a qec.alloc handle (not a qubitref). + let bad = RzOp::new(ctx, alloc.get_result(ctx), Angle64::from_radians(0.5)); + assert!( + verify_op(&bad, ctx).is_err(), + "qec.rz on a qec.alloc handle must fail verification" + ); + } + + /// `qec.if`'s condition must be an `i1`; a non-`i1` (here a qubitref) must be rejected -- guards + /// the IfOp verifier tightened from `verifier = "succ"`. + #[test] + fn negative_if_non_i1_condition_rejected() { + let ctx = &mut Context::new(); + let alloc = QallocOp::new(ctx); + let s0v = SlotOp::new(ctx, alloc.get_result(ctx), 0).get_result(ctx); + let ifop = IfOp::new(ctx, s0v); // qubitref condition -- not an i1 + ifop.make_region_block(ctx, 0); + ifop.make_region_block(ctx, 1); + let err = verify_op(&ifop, ctx) + .expect_err("qec.if with a non-i1 condition must fail verification"); + assert!( + format!("{}", err.disp(ctx)).contains("i1"), + "expected the i1-condition rejection, got: {}", + err.disp(ctx) + ); + } + + /// The plan-build assertion catches registry/IR drift: a measurement whose registry qubit + /// disagrees with its IR slot index must panic (the registry is a side-table `verify` can't check). + #[test] + #[should_panic(expected = "registry/IR drift")] + fn registry_ir_drift_panics_at_plan_build() { + let ctx = &mut Context::new(); + let module = ModuleOp::new(ctx, "drift".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; + } + let qv = push!(QallocOp::new(ctx)).get_result(ctx); + let sv = push!(SlotOp::new(ctx, qv, 0)).get_result(ctx); // slot index 0 + push!(PrepareOp::new(ctx, sv)); + let m = push!(MeasureOp::new(ctx, sv)); + let mut reg = MeasurementRegistry::default(); + reg.record( + m.get_result(ctx), + MeasurementInfo { + qubit: 1, + basis: Basis::Z, + export_label: 0, + }, + ); // qubit 1 != slot 0 + let _ = plan_from_if_ir(ctx, bb, ®); // measure_to_mz must assert and panic + } + + /// The `qec.angle` attribute round-trips an `Angle64` through the IR exactly (fixed-point, no + /// f64 bit-pattern hack): the fraction stored on a `qec.rz` reads back bit-identical. + #[test] + fn angle_attr_roundtrips_fixed_point() { + let ctx = &mut Context::new(); + let module = ModuleOp::new(ctx, "ang".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; + } + let q = push!(QallocOp::new(ctx)); + let sv = push!(SlotOp::new(ctx, q.get_result(ctx), 0)).get_result(ctx); + let angle = Angle64::from_radians(1.07); + let rz = push!(RzOp::new(ctx, sv, angle)); + let got = get_angle(ctx, rz.get_operation()); + assert_eq!( + got.fraction(), + angle.fraction(), + "qec.angle must round-trip the Angle64 fixed-point fraction exactly" + ); + } + + /// Recording a measurement defined *inside* a `qec.if` region from the OUTER block is a + /// cross-region SSA escape (it needs block-args/yield -- Phase 2). `qec.record`'s verifier must + /// reject it, not silently accept it. + #[test] + fn negative_record_of_in_region_measurement_rejected() { + let ctx = &mut Context::new(); + let module = ModuleOp::new(ctx, "bad_region".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; + } + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let s0v = push!(SlotOp::new(ctx, qv, 0)).get_result(ctx); + let s1v = push!(SlotOp::new(ctx, qv, 1)).get_result(ctx); + push!(PrepareOp::new(ctx, s0v)); + push!(PrepareOp::new(ctx, s1v)); + push!(HOp::new(ctx, s0v)); + let m0v = push!(MeasureOp::new(ctx, s0v)).get_result(ctx); + let ifop = push!(IfOp::new(ctx, m0v)); + let then_bb = ifop.make_region_block(ctx, 0); + let m1 = MeasureOp::new(ctx, s1v); // measure INSIDE the then-region + m1.get_operation().insert_at_back(then_bb, ctx); + let m1v = m1.get_result(ctx); + let _else_bb = ifop.make_region_block(ctx, 1); + push!(RecordOp::new(ctx, m1v)); // record it from the OUTER block -- the cross-region escape + push!(EndOp::new(ctx)); + let err = verify_op(&module, ctx).expect_err( + "recording an in-qec.if-region measurement from the outer block must fail verification", + ); + // bite for the RIGHT reason: the region-scope check, not some incidental failure. + assert!( + format!("{}", err.disp(ctx)).contains("cross-region escape"), + "expected the cross-region-escape rejection, got: {}", + err.disp(ctx) + ); + } + + /// `ByteMessage::create_empty()` is *semantically* empty (`is_empty() == Ok(true)`) even though + /// its `as_bytes()` is not byte-empty -- the M2-era footgun that made single-batch engines loop + /// forever until they signalled `EngineStage::Complete` explicitly. Lock the invariant down. + #[test] + fn empty_message_is_semantically_empty() { + let empty = ByteMessage::create_empty(); + assert!( + empty.is_empty().unwrap(), + "create_empty() must be semantically empty (is_empty()==Ok(true))" + ); + assert!( + !empty.as_bytes().is_empty(), + "create_empty().as_bytes() is NOT byte-empty -- that is the footgun" + ); + } + + /// A `result_record_output` that names a result-id no measurement produced must return a + /// structured error (not silently record a default 0, not panic). Guards the export resolution. + #[test] + fn negative_record_of_unknown_result_id_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 9, i8* null) + ret i64 0 +} +"; + let ctx = &mut Context::new(); + let Err(err) = parse_qprog_ll(ctx, BAD) else { + panic!("recording an unknown result-id must error, but the parse succeeded"); + }; + assert!( + format!("{err}").contains("unknown result-id 9"), + "expected the unknown-result-id rejection, got: {err}" + ); + } + + /// A QIS call outside the covered subset (here an `s` gate) must be rejected with a structured + /// error, NOT silently dropped (the old behavior). + #[test] + fn negative_unsupported_gate_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + call void @__quantum__qis__s__body(i64 0) + %r0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + ret i64 0 +} +"; + let ctx = &mut Context::new(); + let Err(err) = parse_bell_ll(ctx, BAD) else { + panic!("an unsupported gate must error, but the parse succeeded"); + }; + assert!( + format!("{err}").contains("not in the covered subset"), + "expected the unsupported-operation rejection, got: {err}" + ); + } + + /// More than one conditional branch is outside the single-diamond subset and must be rejected + /// (not misparsed by taking only the first branch). + #[test] + fn negative_multiple_conditional_branches_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %m0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + %c0 = icmp eq i32 %m0, 1 + br i1 %c0, label %a, label %b +a: + %m1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + %c1 = icmp eq i32 %m1, 1 + br i1 %c1, label %c, label %d +b: + br label %d +c: + br label %d +d: + ret i64 0 +} +"; + let ctx = &mut Context::new(); + let Err(err) = parse_qprog_ll(ctx, BAD) else { + panic!("more than one conditional branch must error, but the parse succeeded"); + }; + assert!( + format!("{err}").contains("more than one conditional branch"), + "expected the unsupported-control-flow rejection, got: {err}" + ); + } + + /// A CFG with only unconditional branches used to route through the straight-line parser and get + /// flattened. It must now be rejected by the adapter classifier before lowering. + #[test] + fn negative_unconditional_cfg_without_conditional_branch_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + br label %tail +tail: + %r0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("unconditional CFG must be rejected, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("straight-line subset"), + "expected the straight-line CFG rejection, got: {err}" + ); + } + + /// Both conditional branch labels must name real blocks; missing labels must not become empty + /// branch bodies via `unwrap_or_default`. + #[test] + fn negative_missing_conditional_branch_label_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %missing +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("missing branch target must error, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("target missing is missing"), + "expected the missing-label rejection, got: {err}" + ); + } + + /// The single-diamond subset requires both arms to merge to the same block; otherwise the qec.if + /// lift would silently pick one post block and misrepresent the CFG. + #[test] + fn negative_diamond_arms_with_different_merges_error() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final_a +skip_x: + br label %final_b +final_a: + ret i64 0 +final_b: + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("diamond with two merge blocks must error, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("same merge block"), + "expected the wrong-merge rejection, got: {err}" + ); + } + + /// The adaptive branch must be driven by the entry block's trailing mid-measurement, not by an + /// unrelated condition that the current qec.if lowering cannot model. + #[test] + fn negative_conditional_branch_without_mid_measure_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + %cond = icmp eq i32 0, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!( + "conditional branch without mid-measurement must error, but adapter lowering succeeded" + ); + }; + assert!( + format!("{err}").contains("mid-measurement"), + "expected the mid-measurement-shape rejection, got: {err}" + ); + } + + /// Extra blocks are outside the one-diamond contract and must not be ignored while choosing the + /// first non-arm label as the merge block. + #[test] + fn negative_extra_block_outside_single_diamond_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + ret i64 0 +dead: + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("extra basic block must error, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("extra basic block dead"), + "expected the extra-block rejection, got: {err}" + ); + } + + // ---- branch-condition dataflow negatives (round-7: prove the branch uses the trailing mid-measure) ---- + + /// A constant branch condition (`icmp eq i32 0, 1`) passes the CFG-shape check but is NOT driven + /// by the measurement; it must be rejected, not mis-lowered as `qec.if(mid)`. + #[test] + fn negative_branch_condition_is_constant_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 0, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("a constant branch condition must error, but lowering succeeded"); + }; + assert!( + format!("{err}").contains("must compare a measurement result"), + "expected the constant-condition rejection, got: {err}" + ); + } + + /// Two measurements, branch on the *earlier* one: the lowering conditions on the trailing + /// measure, so this would mis-lower. Must be rejected. + #[test] + fn negative_branch_on_earlier_measurement_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %m0 = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %m1 = call i32 @__quantum__qis__m__body(i64 1, i64 3) + %cond = icmp eq i32 %m0, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("branching on an earlier measurement must error, but lowering succeeded"); + }; + assert!( + format!("{err}").contains("trailing mid-measurement"), + "expected the wrong-measurement rejection, got: {err}" + ); + } + + /// The conditional branch uses a value other than the entry `icmp` result -- reject. + #[test] + fn negative_branch_not_using_icmp_result_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %arg, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("a branch not using the icmp result must error, but lowering succeeded"); + }; + assert!( + format!("{err}").contains("does not use the entry `icmp` result"), + "expected the branch-not-using-icmp rejection, got: {err}" + ); + } + + /// `icmp eq , 0` would swap the then/else sense (the lowering assumes mid==1 -> then-arm); + /// only `eq ..., 1` is supported. + #[test] + fn negative_branch_condition_compares_zero_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 0 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("an `icmp eq , 0` condition must error, but lowering succeeded"); + }; + assert!( + format!("{err}").contains("icmp eq , 1"), + "expected the only-eq-1-supported rejection, got: {err}" + ); + } +} diff --git a/crates/pecos-phir/src/execution/engine.rs b/crates/pecos-phir/src/execution/engine.rs index 8eac518d0..a73f2c410 100644 --- a/crates/pecos-phir/src/execution/engine.rs +++ b/crates/pecos-phir/src/execution/engine.rs @@ -131,6 +131,12 @@ impl ClassicalEngine for PhirEngine { self.processor.get_qubit_count() } + fn has_dynamic_qubit_count(&self) -> bool { + // PHIR allocates qubits during command generation, so the count is 0 + // before execution and grows as qalloc operations are discovered. + true + } + fn generate_commands(&mut self) -> std::result::Result { const MAX_BATCH_SIZE: usize = 100; diff --git a/crates/pecos-phir/src/execution/tests.rs b/crates/pecos-phir/src/execution/tests.rs index 2016c3888..476875cd7 100644 --- a/crates/pecos-phir/src/execution/tests.rs +++ b/crates/pecos-phir/src/execution/tests.rs @@ -1728,3 +1728,36 @@ fn test_engine_rz_rxy_module() -> Result<(), Box> { Ok(()) } + +#[test] +fn test_dynamic_zero_engine_rejected_without_explicit_qubits() { + use super::phir_engine; + use pecos_engines::sim_builder; + + // A PhirEngine reports 0 qubits before execution but allocates qubits + // dynamically at runtime. Building a sim with the default (fixed-size, non- + // growing) quantum engine and no explicit qubit count must FAIL LOUD rather + // than build a 0-qubit engine that would panic on the first allocation. This + // exercises the whole inferred-zero guard end-to-end: the guard keys on + // `ClassicalEngine::has_dynamic_qubit_count`, which must be forwarded through + // the `Box` SimBuilder holds (a regression there + // silently makes the guard inert and this test fails). + let engine = phir_engine().program(create_test_module()); + let result = sim_builder().classical(engine).build(); + assert!( + result.is_err(), + "dynamic-zero engine + default quantum engine must be rejected" + ); + let msg = result.err().unwrap().to_string(); + assert!( + msg.contains("dynamic classical engine"), + "expected the inferred-zero guard error, got: {msg}" + ); + + // An explicit qubit count bypasses the guard and builds fine. + let engine = phir_engine().program(create_test_module()); + assert!( + sim_builder().classical(engine).qubits(1).build().is_ok(), + "explicit .qubits(n) must bypass the inferred-zero guard" + ); +} diff --git a/crates/pecos-phir/src/hugr_parser.rs b/crates/pecos-phir/src/hugr_parser.rs index f4521fba9..c03cca3ca 100644 --- a/crates/pecos-phir/src/hugr_parser.rs +++ b/crates/pecos-phir/src/hugr_parser.rs @@ -155,6 +155,13 @@ impl HugrToPhirConverter { /// Top-level conversion: HUGR -> PHIR Module. fn convert(&mut self, hugr: &Hugr) -> Result { + // This converter is straight-line only. `find_operations_container` + // keeps just the FIRST `DataflowBlock` of a CFG, so a HUGR with real + // control flow would silently lose every other block (commonly + // dropping the measurements -> empty results). Reject it up front so + // callers fall back to `HugrEngine` instead of getting wrong output. + Self::reject_control_flow(hugr)?; + let mut module = ModuleOp::new("hugr_module"); // Find the container node (DFG or DataflowBlock) holding operations @@ -241,6 +248,34 @@ impl HugrToPhirConverter { Ok(module) } + /// Reject HUGR that uses classical control flow (loops, conditionals). + /// + /// Guppy compiles a straight-line body to a CFG with a single + /// `DataflowBlock` (plus an `ExitBlock`). More than one `DataflowBlock` + /// under any CFG means real control flow, which this straight-line + /// converter cannot represent: it keeps only the first block. Returning an + /// error here turns that silent data loss into a clean failure. + fn reject_control_flow(hugr: &Hugr) -> Result<()> { + for node in hugr.nodes() { + if matches!(hugr.get_optype(node), OpType::CFG(_)) { + let blocks = hugr + .children(node) + .filter(|&child| matches!(hugr.get_optype(child), OpType::DataflowBlock(_))) + .count(); + if blocks > 1 { + return Err(PhirError::Parse(Box::new( + crate::error::ParseError::Unsupported { + feature: format!("classical control flow ({blocks} basic blocks)"), + format: "HUGR".to_string(), + location: crate::error::SourceLocation::unknown(), + }, + ))); + } + } + } + Ok(()) + } + /// Find the container node whose children are the quantum operations. /// /// Guppy-compiled HUGRs use: `Module -> FuncDefn -> CFG -> DataflowBlock`. diff --git a/crates/pecos-pymatching/src/core_traits.rs b/crates/pecos-pymatching/src/core_traits.rs index 8a96c2b16..bf1690a3e 100644 --- a/crates/pecos-pymatching/src/core_traits.rs +++ b/crates/pecos-pymatching/src/core_traits.rs @@ -188,14 +188,17 @@ impl DetailedDecoder for PyMatchingDecoder { /// /// Converts the observable vector to a bitmask for the sample+decode loop. impl ObservableDecoder for PyMatchingDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let result = self .decode(syndrome) .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; - let mut mask = 0u64; + let mut mask = pecos_decoder_core::obs_mask::ObsMask::new(); for (i, &v) in result.observable.iter().enumerate() { if v != 0 { - mask |= 1 << i; + mask.set(i); } } Ok(mask) @@ -217,6 +220,18 @@ impl ObservableDecoder for PyMatchingDecoder { .decode_batch_with_config(shots, num_shots, num_detectors, config) .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; + // This narrow batch API packs observables into a u64 (8 bit-packed + // bytes), so it caps at 64 observables; a wider decode would overflow the + // shift. Fail loud rather than truncate -- callers needing >64 observables + // use the wide per-shot `decode_obs` path. + if result.predictions.first().is_some_and(|p| p.len() > 8) { + return Err(DecoderError::InvalidConfiguration( + "decode_batch_to_observables packs observables into a u64 and supports at most \ + 64 observables; use decode_obs for wider observable sets" + .to_string(), + )); + } + // Convert per-shot bit-packed predictions to u64 masks. let mut masks = Vec::with_capacity(num_shots); for pred in &result.predictions { diff --git a/crates/pecos-qasm/src/unified_engine_builder.rs b/crates/pecos-qasm/src/unified_engine_builder.rs index 0c44f6652..110b8c84a 100644 --- a/crates/pecos-qasm/src/unified_engine_builder.rs +++ b/crates/pecos-qasm/src/unified_engine_builder.rs @@ -162,6 +162,19 @@ impl QasmEngineBuilder { self.source.is_some() } + /// Check if this builder has a WASM foreign-function program configured + #[must_use] + pub fn has_wasm(&self) -> bool { + #[cfg(feature = "wasm")] + { + self.wasm_program.is_some() + } + #[cfg(not(feature = "wasm"))] + { + false + } + } + /// Get the `Qasm` from this builder (if any) #[must_use] pub fn get_program(&self) -> Option { diff --git a/crates/pecos-qec/Cargo.toml b/crates/pecos-qec/Cargo.toml index 30a043360..e11bfaf2c 100644 --- a/crates/pecos-qec/Cargo.toml +++ b/crates/pecos-qec/Cargo.toml @@ -19,6 +19,8 @@ pecos-num.workspace = true pecos-quantum.workspace = true pecos-simulators.workspace = true pecos-random.workspace = true +# Optional: PerGateTypeNoise -> neo PerGatePauliChannel conversion +pecos-neo = { workspace = true, optional = true } rand.workspace = true rand_core.workspace = true rayon.workspace = true @@ -27,6 +29,15 @@ smallvec.workspace = true thiserror.workspace = true wide.workspace = true +[features] +# Convert PerGateTypeNoise to the pecos-neo noise channel for +# circuit-level Monte Carlo with the same noise that drives DEM export. +neo = ["dep:pecos-neo"] + +[[test]] +name = "per_gate_neo_mapping_tests" +required-features = ["neo"] + [[example]] name = "surface_d3_fault_catalog_lookup" path = "../../examples/surface/d3_fault_catalog_lookup.rs" diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index 8aa05b7a7..7997b32eb 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -25,6 +25,7 @@ pub mod fault_sampler; pub mod gadget_checker; pub mod influence_builder; pub mod lookup_decoder; +pub mod pauli_frame; pub mod pauli_prop_checker; pub mod propagator; pub mod stabilizer_flip_checker; @@ -47,6 +48,7 @@ pub use gadget_checker::{ GadgetSyndromeAnalysis, }; pub use influence_builder::InfluenceBuilder; +pub use pauli_frame::{PauliFrameLookup, PauliFrameLookupError}; pub use pauli_prop_checker::{ DecoderAnalysis, FaultClass, FaultToleranceAnalysis, FaultToleranceFailure, FollowUpConfig, MeasurementRound, PauliPropChecker, PropagationResult, SyndromeAnalysis, SyndromeClass, diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index a6af42108..01a6649dd 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -46,10 +46,12 @@ //! //! # Error Decomposition //! -//! When using decomposed DEM output, hyperedge errors (affecting 3+ -//! detectors) are decomposed into combinations of graphlike errors (affecting -//! 1-2 detectors). This is necessary for MWPM decoders which only work on -//! graphs, not hypergraphs. +//! When using decomposed DEM output, PECOS decomposes only through component +//! structure carried by the original fault source (for example `Y = X ^ Z` or +//! recorded per-location components for multi-qubit sources). Residual +//! hyperedges remain hyperedges. Graphlike decoders should consume this output +//! only after checking that no residual hyperedge components remain; hypergraph +//! decoders can consume the faithful hypergraph model directly. //! //! # Comparison with Python Implementation //! @@ -98,8 +100,10 @@ pub use sampler::{ pub use types::{ ContributionEffectSummary, ContributionRenderRecord, ContributionRenderStrategy, ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, - DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, MeasurementMechanism, + DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, + MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, - PecosDemMetadataError, PerGateTypeNoise, TwoDetectorDirectRenderPolicy, combine_probabilities, - record_offset_to_absolute_index, + PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, + ReplacementBranchImpact, TwoDetectorDirectRenderPolicy, combine_probabilities, + omitted_two_qubit_gate_pauli_twirl, record_offset_to_absolute_index, }; diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index b6ce524d5..2d70667c6 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -16,14 +16,22 @@ //! influence maps and detector/DEM-output metadata. use super::types::{ - DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, FaultMechanism, - NoiseConfig, PerGateTypeNoise, SourceMetadata, record_offset_to_absolute_index, + DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, DirectSourceFamily, + FaultMechanism, MeasurementCrosstalkDemMode, NoiseConfig, PerGateTypeNoise, + ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; -use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; +use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Direction, Pauli, apply_gate}; +use pecos_core::BitSet; use pecos_core::gate_type::GateType; +use pecos_simulators::{ + PauliProp, SymbolicMeasurementResult, SymbolicSparseStab, + symbolic_sparse_stab::MeasurementHistory, +}; use smallvec::SmallVec; +use std::cell::RefCell; use std::collections::BTreeMap; +use std::rc::Rc; // ============================================================================ // JSON Parsing Types @@ -111,6 +119,36 @@ pub struct DemBuilder<'a> { /// Optional measurement order: maps `TickCircuit` measurement index -> qubit. /// This allows proper mapping between record offsets and influence map indices. measurement_order: Option>, + /// Optional circuit context for future exact replacement-branch replay. + exact_branch_context: Option>, + /// Ideal symbolic measurement history shared by exact branch replays. + exact_ideal_history_cache: RefCell>>, + /// Per-gate cache for exact replacement-branch replay effects. + exact_branch_cache: RefCell>, +} + +#[derive(Debug, Clone, Copy)] +struct ExactBranchReplayContext<'a> { + circuit: &'a pecos_quantum::DagCircuit, +} + +#[derive(Debug, Clone)] +struct ExactBranchReplayAnalysis { + base_effect: FaultMechanism, + branch_effects: [[FaultMechanism; 4]; 4], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExactBranchReplayRequest { + gate_node: usize, + gate_type: GateType, + loc_indices: [usize; 2], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MeasurementParityExpression { + dependencies: BitSet, + flip: bool, } impl<'a> DemBuilder<'a> { @@ -163,7 +201,23 @@ impl<'a> DemBuilder<'a> { p_meas: f64, p_prep: f64, ) -> Result { - build_dem_from_circuit(circuit, p1, p2, p_meas, p_prep) + build_dem_from_circuit(circuit, NoiseConfig::new(p1, p2, p_meas, p_prep)) + } + + /// Try to build a `DetectorErrorModel` directly from a `DagCircuit` and + /// full noise configuration. + /// + /// Reads detector/DEM output definitions from circuit metadata and returns + /// parser errors instead of dropping malformed metadata. + /// + /// # Errors + /// + /// Returns an error if detector or observable metadata is malformed. + pub fn try_from_circuit_with_noise_config( + circuit: &pecos_quantum::DagCircuit, + noise: NoiseConfig, + ) -> Result { + build_dem_from_circuit(circuit, noise) } /// Build a `DetectorErrorModel` from a `TickCircuit` and noise. @@ -202,7 +256,24 @@ impl<'a> DemBuilder<'a> { p_prep: f64, ) -> Result { let dag = pecos_quantum::DagCircuit::from(circuit); - build_dem_from_circuit(&dag, p1, p2, p_meas, p_prep) + build_dem_from_circuit(&dag, NoiseConfig::new(p1, p2, p_meas, p_prep)) + } + + /// Try to build a `DetectorErrorModel` from a `TickCircuit` and full noise + /// configuration. + /// + /// Converts to `DagCircuit` internally and returns parser errors instead + /// of dropping malformed metadata. + /// + /// # Errors + /// + /// Returns an error if detector or observable metadata is malformed. + pub fn try_from_tick_circuit_with_noise_config( + circuit: &pecos_quantum::TickCircuit, + noise: NoiseConfig, + ) -> Result { + let dag = pecos_quantum::DagCircuit::from(circuit); + build_dem_from_circuit(&dag, noise) } /// Creates a new DEM builder from a fault influence map. @@ -216,9 +287,16 @@ impl<'a> DemBuilder<'a> { observables: Vec::new(), num_measurements: influence_map.measurements.len(), measurement_order: None, + exact_branch_context: None, + exact_ideal_history_cache: RefCell::new(None), + exact_branch_cache: RefCell::new(BTreeMap::new()), } } + fn clear_exact_branch_cache(&mut self) { + self.exact_branch_cache.get_mut().clear(); + } + /// Sets the noise configuration from individual parameters. #[must_use] pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { @@ -233,6 +311,17 @@ impl<'a> DemBuilder<'a> { self } + #[must_use] + pub fn with_exact_branch_replay_context( + mut self, + circuit: &'a pecos_quantum::DagCircuit, + ) -> Self { + self.exact_branch_context = Some(ExactBranchReplayContext { circuit }); + self.exact_ideal_history_cache.get_mut().take(); + self.clear_exact_branch_cache(); + self + } + /// Attach per-gate-type per-Pauli noise. When present, overrides /// [`Self::with_noise`] scalars for gate types in the spec's maps. /// Mirrors @@ -282,15 +371,16 @@ impl<'a> DemBuilder<'a> { pg.rate_1q(loc.gate_type, 2), ]; } + let p1_total = self.noise.p1_rate_for_gate(loc.gate_type); if let Some(weights) = &self.noise.p1_weights { use pecos_core::pauli::{X, Y, Z}; return [ - self.noise.p1 * weights.weight_for(&X(0)), - self.noise.p1 * weights.weight_for(&Y(0)), - self.noise.p1 * weights.weight_for(&Z(0)), + p1_total * weights.weight_for(&X(0)), + p1_total * weights.weight_for(&Y(0)), + p1_total * weights.weight_for(&Z(0)), ]; } - let per = per_channel_probability(self.noise.p1, 3); + let per = per_channel_probability(p1_total, 3); [per, per, per] } @@ -306,8 +396,7 @@ impl<'a> DemBuilder<'a> { return rates; } if pg.base.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = pg.base.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -315,8 +404,7 @@ impl<'a> DemBuilder<'a> { } if self.noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = self.noise.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -347,16 +435,32 @@ impl<'a> DemBuilder<'a> { let flat = idx + 1; let p1 = flat / 4; let p2 = flat % 4; - self.noise.p2 * weights.weight_for(&pauli_pair_for_weight(p1, p2)) + let pauli = pauli_pair_for_weight(p1, p2); + let p2_total = self.noise.p2_rate_for_gate(loc1.gate_type); + let weight = if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::BranchImpact + || self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay + { + weights.post_gate_two_qubit_weight_for(&pauli) + } else { + weights.two_qubit_weight_for( + loc1.gate_type, + &pauli, + self.noise.p2_replacement_approximation, + ) + }; + p2_total * weight }); } - [per_channel_probability(self.noise.p2, 15); 15] + [per_channel_probability(self.noise.p2_rate_for_gate(loc1.gate_type), 15); 15] } /// Sets the number of measurements (used for record offset calculation). #[must_use] pub fn with_num_measurements(mut self, num: usize) -> Self { self.num_measurements = num; + self.clear_exact_branch_cache(); self } @@ -381,6 +485,7 @@ impl<'a> DemBuilder<'a> { #[must_use] pub fn with_measurement_order(mut self, order: Vec) -> Self { self.measurement_order = Some(order); + self.clear_exact_branch_cache(); self } @@ -401,6 +506,7 @@ impl<'a> DemBuilder<'a> { /// Returns an error if the JSON is malformed. pub fn with_detectors_json(mut self, json: &str) -> Result { self.detectors = parse_detectors_json(json)?; + self.clear_exact_branch_cache(); Ok(self) } @@ -416,6 +522,7 @@ impl<'a> DemBuilder<'a> { /// Returns an error if the JSON is malformed. pub fn with_observables_json(mut self, json: &str) -> Result { self.observables = parse_observables_json(json)?; + self.clear_exact_branch_cache(); Ok(self) } @@ -432,6 +539,7 @@ impl<'a> DemBuilder<'a> { meas_ids: Vec::new(), }) .collect(); + self.clear_exact_branch_cache(); self } @@ -537,6 +645,38 @@ impl<'a> DemBuilder<'a> { .collect() } + fn measurement_indices_from_refs( + &self, + records: &[i32], + meas_ids: &[usize], + ) -> Result, DemBuilderError> { + if !records.is_empty() { + return records + .iter() + .map(|&rec| { + record_offset_to_absolute_index(self.num_measurements, rec).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "record offset {rec} is out of range for a circuit with {} measurement(s)", + self.num_measurements + )) + }) + }) + .collect(); + } + + meas_ids + .iter() + .map(|&meas_id| { + self.resolve_meas_id_to_tc_index(meas_id).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "meas_id {meas_id} is not present in the circuit's {} measurement(s)", + self.num_measurements + )) + }) + }) + .collect() + } + /// Validates metadata refs, then builds the Detector Error Model. /// /// This is the fail-loud entry point. Every path that ingests @@ -597,6 +737,8 @@ impl<'a> DemBuilder<'a> { pub fn try_build(&self) -> Result { self.validate_measurement_count()?; self.validate_metadata_refs()?; + self.validate_replacement_branch_approximation()?; + self.validate_measurement_crosstalk_dem_mode()?; Ok(self.build()) } @@ -609,8 +751,18 @@ impl<'a> DemBuilder<'a> { /// /// This does **not** validate metadata refs; callers ingesting /// circuit-derived metadata must use [`Self::try_build`] instead. + /// + /// # Panics + /// + /// Panics if the configured replacement-branch approximation or measurement + /// crosstalk DEM mode is invalid. Use [`Self::try_build`] to receive those as + /// errors instead of panicking. #[must_use] pub fn build(&self) -> DetectorErrorModel { + self.validate_replacement_branch_approximation() + .expect("invalid DEM replacement branch approximation"); + self.validate_measurement_crosstalk_dem_mode() + .expect("invalid DEM measurement crosstalk configuration"); let num_influence_dem_outputs = self .num_influence_dem_outputs() .max(self.influence_map.dem_output_metadata.len()); @@ -677,6 +829,579 @@ impl<'a> DemBuilder<'a> { dem } + fn validate_replacement_branch_approximation(&self) -> Result<(), DemBuilderError> { + let has_replacement_branches = self + .noise + .p2_weights + .as_ref() + .is_some_and(super::types::PauliWeights::has_replacement_entries); + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay + && has_replacement_branches + { + if let Some(context) = self.exact_branch_context { + let requests = + context.replacement_branch_requests(&self.influence_map.locations)?; + let weights = self + .noise + .p2_weights + .as_ref() + .expect("replacement entries exist"); + for request in &requests { + for (replacement_pauli, _weight) in weights.replacement_entries() { + let label = two_qubit_label_for_replay(replacement_pauli)?; + let _replacement_effect = + self.exact_replacement_branch_effect(context, *request, &label)?; + } + } + return Ok(()); + } + return Err(DemBuilderError::ConfigurationError( + "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" + .to_string(), + )); + } + Ok(()) + } + + fn validate_measurement_crosstalk_dem_mode(&self) -> Result<(), DemBuilderError> { + if self.noise.measurement_crosstalk_dem_mode == MeasurementCrosstalkDemMode::Omitted { + return Ok(()); + } + + let has_local_payloads = self + .influence_map + .locations + .iter() + .any(|loc| !loc.before && loc.gate_type == GateType::MeasCrosstalkLocalPayload); + let has_global_payloads = self + .influence_map + .locations + .iter() + .any(|loc| !loc.before && loc.gate_type == GateType::MeasCrosstalkGlobalPayload); + + if !self.noise.p_meas_crosstalk_model.is_valid() { + return Err(DemBuilderError::ConfigurationError( + "measurement crosstalk transition probabilities must be finite, non-negative, and have each hidden-outcome row sum <= 1" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_global > 0.0 && !has_global_payloads { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay requested a positive global rate, but the influence map contains no MeasCrosstalkGlobalPayload locations" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_local > 0.0 && !has_local_payloads { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay requested a positive local rate, but the influence map contains no MeasCrosstalkLocalPayload locations" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_local <= 0.0 && self.noise.p_meas_crosstalk_global <= 0.0 { + return Ok(()); + } + + if self.noise.p_meas_crosstalk_model.has_leakage() + && !matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) + { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay does not yet support leakage transitions" + .to_string(), + )); + } + + let needs_circuit_context = + self.noise.measurement_crosstalk_dem_mode != MeasurementCrosstalkDemMode::Omitted; + let Some(context) = self.exact_branch_context else { + if needs_circuit_context { + return Err(DemBuilderError::ConfigurationError( + "measurement crosstalk DEM replay requires a circuit-aware builder context" + .to_string(), + )); + } + return Ok(()); + }; + + let requires_deterministic_hidden_measurements = matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + ); + if !requires_deterministic_hidden_measurements { + return Ok(()); + } + + for (loc_idx, loc) in self.influence_map.locations.iter().enumerate() { + if loc.before + || !matches!( + loc.gate_type, + GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload + ) + { + continue; + } + let result = Self::hidden_mz_result_before_crosstalk_payload(context, loc)?; + if !result.is_deterministic || !result.outcome.is_empty() { + return Err(DemBuilderError::ConfigurationError(format!( + "exact deterministic measurement crosstalk DEM replay requires a state-independent hidden MZ result at location {loc_idx} (node {}, qubit {:?}); got deterministic={}, dependencies={:?}", + loc.node, + loc.qubits.first(), + result.is_deterministic, + result.outcome + ))); + } + } + + Ok(()) + } + + fn hidden_mz_result_before_crosstalk_payload( + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + ) -> Result { + let qubit = loc.qubits.first().copied().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload at node {} has no victim qubit", + loc.node + )) + })?; + let qubit_index = qubit.index(); + let topo_order = context.circuit.topological_order(); + let max_qubit = topo_order + .iter() + .filter_map(|&node| context.circuit.gate(node)) + .flat_map(|gate| gate.qubits.iter()) + .map(pecos_core::QubitId::index) + .max() + .unwrap_or(qubit_index); + let mut sim = SymbolicSparseStab::new(max_qubit.max(qubit_index) + 1); + + for node in topo_order { + if node == loc.node { + return sim.mz(&[qubit_index]).into_iter().next().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "failed to synthesize hidden MZ for measurement crosstalk payload at node {}", + loc.node + )) + }); + } + + if let Some(gate) = context.circuit.gate(node) { + let qubits: Vec = + gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + Self::apply_symbolic_gate_for_crosstalk_hidden_mz( + &mut sim, + node, + gate.gate_type, + &qubits, + )?; + } + } + + Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload node {} was not found in the replay circuit", + loc.node + ))) + } + + fn exact_measurement_crosstalk_pauli_effect( + &self, + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + pauli: Pauli, + ) -> Result { + let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); + let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); + + for detector in &self.detectors { + let indices = + self.measurement_indices_from_refs(&detector.records, &detector.meas_ids)?; + if self.measurement_parity_anticommutes_after_crosstalk_payload( + context, loc, pauli, &indices, + )? { + xor_toggle_4(&mut triggered_dets, detector.id); + } + } + + for observable in &self.observables { + let indices = + self.measurement_indices_from_refs(&observable.records, &observable.meas_ids)?; + if self.measurement_parity_anticommutes_after_crosstalk_payload( + context, loc, pauli, &indices, + )? { + xor_toggle_2(&mut triggered_obs, observable.id); + } + } + + triggered_dets.sort_unstable(); + triggered_obs.sort_unstable(); + Ok(FaultMechanism::from_sorted_with_tracked_paulis( + triggered_dets, + triggered_obs, + SmallVec::new(), + )) + } + + fn measurement_parity_anticommutes_after_crosstalk_payload( + &self, + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + pauli: Pauli, + measurement_indices: &[usize], + ) -> Result { + let victim = loc.qubits.first().copied().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload at node {} has no victim qubit", + loc.node + )) + })?; + let victim_index = victim.index(); + let mut prop = PauliProp::new(); + for &measurement_idx in measurement_indices { + let &(_, qubit, basis) = self + .influence_map + .measurements + .get(measurement_idx) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact replay has no measurement {measurement_idx}" + )) + })?; + match basis { + 0 => prop.track_z(&[qubit]), + 1 => prop.track_x(&[qubit]), + other => { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact replay does not support measurement basis {other}" + ))); + } + } + } + + let topo_order = context.circuit.topological_order(); + let payload_pos = topo_order + .iter() + .position(|&node| node == loc.node) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload node {} was not found in the replay circuit", + loc.node + )) + })?; + for &node in topo_order[payload_pos + 1..].iter().rev() { + if let Some(gate) = context.circuit.gate(node) { + apply_gate(&mut prop, gate, Direction::Backward); + } + } + + Ok(Self::pauli_anticommutes_with_prop_on_qubit( + &prop, + victim_index, + pauli, + )) + } + + fn pauli_anticommutes_with_prop_on_qubit(prop: &PauliProp, qubit: usize, pauli: Pauli) -> bool { + let has_x = prop.contains_x(qubit); + let has_z = prop.contains_z(qubit); + match pauli { + Pauli::I => false, + Pauli::X => has_z, + Pauli::Z => has_x, + Pauli::Y => has_x ^ has_z, + } + } + + fn apply_symbolic_gate_for_crosstalk_hidden_mz( + sim: &mut SymbolicSparseStab, + node: usize, + gate_type: GateType, + qubits: &[usize], + ) -> Result<(), DemBuilderError> { + let require = |n: usize| -> Result<(), DemBuilderError> { + if qubits.len() < n { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk replay expected gate {:?} at node {} to have at least {} qubit(s), got {}", + gate_type, + node, + n, + qubits.len() + ))); + } + Ok(()) + }; + let pairs = || -> Result, DemBuilderError> { + require(2)?; + if !qubits.len().is_multiple_of(2) { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk replay expected gate {:?} at node {} to have an even number of qubits, got {}", + gate_type, + node, + qubits.len() + ))); + } + Ok(qubits + .chunks_exact(2) + .map(|pair| (pair[0], pair[1])) + .collect()) + }; + + match gate_type { + GateType::H => { + require(1)?; + sim.h(qubits); + } + GateType::F => { + require(1)?; + sim.sx(qubits); + sim.sz(qubits); + } + GateType::Fdg => { + require(1)?; + sim.szdg(qubits); + sim.sxdg(qubits); + } + GateType::SX => { + require(1)?; + sim.sx(qubits); + } + GateType::SXdg => { + require(1)?; + sim.sxdg(qubits); + } + GateType::SY => { + require(1)?; + sim.sy(qubits); + } + GateType::SYdg => { + require(1)?; + sim.sydg(qubits); + } + GateType::SZ => { + require(1)?; + sim.sz(qubits); + } + GateType::SZdg => { + require(1)?; + sim.szdg(qubits); + } + GateType::X => { + require(1)?; + sim.x(qubits); + } + GateType::Y => { + require(1)?; + sim.y(qubits); + } + GateType::Z => { + require(1)?; + sim.z(qubits); + } + GateType::CX => { + sim.cx(&pairs()?); + } + GateType::CY => { + sim.cy(&pairs()?); + } + GateType::CZ => { + sim.cz(&pairs()?); + } + GateType::SXX => { + sim.sxx(&pairs()?); + } + GateType::SXXdg => { + sim.sxxdg(&pairs()?); + } + GateType::SYY => { + sim.syy(&pairs()?); + } + GateType::SYYdg => { + sim.syydg(&pairs()?); + } + GateType::SZZ => { + sim.szz(&pairs()?); + } + GateType::SZZdg => { + sim.szzdg(&pairs()?); + } + GateType::SWAP => { + sim.swap(&pairs()?); + } + GateType::MZ | GateType::MeasureFree => { + require(1)?; + sim.mz(qubits); + } + GateType::PZ | GateType::QAlloc => { + require(1)?; + for &qubit in qubits { + sim.pz(qubit); + } + } + GateType::I + | GateType::Idle + | GateType::QFree + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload + | GateType::TrackedPauliMeta => {} + _ => { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact deterministic replay does not support gate {gate_type:?} before payload node {node}" + ))); + } + } + + Ok(()) + } + + #[cfg(test)] + fn exact_omitted_branch_base_effect( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + ) -> Result { + let branch = circuit_with_omitted_two_qubit_gate(context.circuit, request.gate_node)?; + self.exact_omitted_branch_base_effect_for_branch(context, request, &branch) + } + + fn exact_omitted_branch_base_effect_for_branch( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + branch: &pecos_quantum::DagCircuit, + ) -> Result { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + + let ideal_history = self.exact_ideal_measurement_history(context); + let branch_info = InfluenceBuilder::new(branch).run_symbolic_simulation(); + let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); + let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); + + for detector in &self.detectors { + let indices = + self.measurement_indices_from_refs(&detector.records, &detector.meas_ids)?; + if omitted_branch_flips_measurement_parity_from_histories( + request, + ideal_history.as_ref(), + &branch_info.history, + &indices, + )? { + xor_toggle_4(&mut triggered_dets, detector.id); + } + } + + for observable in &self.observables { + let indices = + self.measurement_indices_from_refs(&observable.records, &observable.meas_ids)?; + if omitted_branch_flips_measurement_parity_from_histories( + request, + ideal_history.as_ref(), + &branch_info.history, + &indices, + )? { + xor_toggle_2(&mut triggered_obs, observable.id); + } + } + + triggered_dets.sort_unstable(); + triggered_obs.sort_unstable(); + Ok(FaultMechanism::from_sorted_with_tracked_paulis( + triggered_dets, + triggered_obs, + SmallVec::new(), + )) + } + + fn exact_ideal_measurement_history( + &self, + context: ExactBranchReplayContext<'_>, + ) -> Rc { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + + if let Some(cached) = self.exact_ideal_history_cache.borrow().as_ref().cloned() { + return cached; + } + + let history = Rc::new( + InfluenceBuilder::new(context.circuit) + .run_symbolic_simulation() + .history, + ); + *self.exact_ideal_history_cache.borrow_mut() = Some(history.clone()); + history + } + + fn exact_branch_analysis( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + ) -> Result { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + + if let Some(cached) = self + .exact_branch_cache + .borrow() + .get(&request.gate_node) + .cloned() + { + return Ok(cached); + } + + let branch = circuit_with_omitted_two_qubit_gate(context.circuit, request.gate_node)?; + let base_effect = + self.exact_omitted_branch_base_effect_for_branch(context, request, &branch)?; + let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); + let branch_locs = identity_location_pair_for_request( + request, + &self.influence_map.locations, + &branch_map.locations, + )?; + let (meas_to_detectors, meas_to_observables) = self.build_measurement_mappings(); + let branch_effects = Self::two_qubit_effect_table_for_map( + &branch_map, + branch_locs[0], + branch_locs[1], + &meas_to_detectors, + &meas_to_observables, + ); + let analysis = ExactBranchReplayAnalysis { + base_effect, + branch_effects, + }; + self.exact_branch_cache + .borrow_mut() + .insert(request.gate_node, analysis.clone()); + Ok(analysis) + } + + fn exact_replacement_branch_effect( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + replacement_pauli_label: &str, + ) -> Result { + let (p1, p2) = two_qubit_label_to_pauli_indices(replacement_pauli_label).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay replacement Pauli label {replacement_pauli_label:?} is not a two-qubit Pauli label" + )) + })?; + let analysis = self.exact_branch_analysis(context, request)?; + if p1 == 0 && p2 == 0 { + return Ok(analysis.base_effect); + } + + Ok(analysis + .base_effect + .xor(&analysis.branch_effects[p1 as usize][p2 as usize])) + } + fn num_influence_dem_outputs(&self) -> usize { self.influence_map .influences @@ -696,9 +1421,6 @@ impl<'a> DemBuilder<'a> { ) { let locations = &self.influence_map.locations; - // Group CX locations by node for two-qubit gate processing - let mut cx_groups: BTreeMap> = BTreeMap::new(); - for (loc_idx, loc) in locations.iter().enumerate() { match loc.gate_type { GateType::PZ | GateType::QAlloc @@ -721,23 +1443,43 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } - GateType::CX - | GateType::CZ - | GateType::CY - | GateType::SZZ - | GateType::SZZdg - | GateType::SXX - | GateType::SXXdg - | GateType::SYY - | GateType::SYYdg - | GateType::SWAP - | GateType::RXX - | GateType::RYY - | GateType::RZZ - if !loc.before => + GateType::MeasCrosstalkLocalPayload + if !loc.before + && matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) + && self.noise.p_meas_crosstalk_local > 0.0 => { - cx_groups.entry(loc.node).or_default().push(loc_idx); + self.process_measurement_crosstalk_source_tracked( + loc_idx, + self.noise.p_meas_crosstalk_local, + dem, + meas_to_detectors, + meas_to_observables, + ); } + GateType::MeasCrosstalkGlobalPayload + if !loc.before + && matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) + && self.noise.p_meas_crosstalk_global > 0.0 => + { + self.process_measurement_crosstalk_source_tracked( + loc_idx, + self.noise.p_meas_crosstalk_global, + dem, + meas_to_detectors, + meas_to_observables, + ); + } + gate_type if is_two_qubit_noise_gate(gate_type) && !loc.before => {} GateType::H | GateType::F | GateType::Fdg @@ -787,24 +1529,37 @@ impl<'a> DemBuilder<'a> { } // Process two-qubit gates. - for (_, loc_indices) in cx_groups { - for pair in loc_indices.chunks(2) { - if pair.len() != 2 { - continue; - } - let loc1 = &locations[pair[0]]; - let loc2 = &locations[pair[1]]; - let rates = self.rates_2q_for_locs(loc1, loc2); - if rates.iter().any(|r| *r > 0.0) { - self.process_two_qubit_fault_source_tracked( - pair[0], - pair[1], - rates, - dem, - meas_to_detectors, - meas_to_observables, - ); - } + for [loc1_idx, loc2_idx] in two_qubit_after_location_pairs(locations) { + let loc1 = &locations[loc1_idx]; + let loc2 = &locations[loc2_idx]; + let rates = self.rates_2q_for_locs(loc1, loc2); + if rates.iter().any(|r| *r > 0.0) { + self.process_two_qubit_fault_source_tracked( + loc1_idx, + loc2_idx, + rates, + dem, + meas_to_detectors, + meas_to_observables, + ); + } + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::BranchImpact + { + self.process_two_qubit_replacement_branch_impacts_source_tracked( + loc1_idx, + loc2_idx, + dem, + meas_to_detectors, + meas_to_observables, + ); + } + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay + { + self.process_two_qubit_exact_replacement_branches_source_tracked( + loc1_idx, loc2_idx, dem, + ); } } } @@ -831,6 +1586,112 @@ impl<'a> DemBuilder<'a> { } } + /// Processes starred two-qubit replacement branches as explicit branch impacts. + fn process_two_qubit_replacement_branch_impacts_source_tracked( + &self, + loc1: usize, + loc2: usize, + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let Some(weights) = &self.noise.p2_weights else { + return; + }; + let loc1_meta = &self.influence_map.locations[loc1]; + let branch_impacts = weights.replacement_branch_impacts(loc1_meta.gate_type); + if branch_impacts.is_empty() { + return; + } + + let effects = + self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); + let loc2_meta = &self.influence_map.locations[loc2]; + + for impact in branch_impacts { + let Some((p1, p2)) = two_qubit_label_to_pauli_indices(&impact.pauli_label) else { + continue; + }; + let prob = self.noise.p2 * impact.relative_probability; + Self::add_two_qubit_pauli_contribution( + loc1, + loc2, + p1, + p2, + prob, + &effects, + loc1_meta, + loc2_meta, + dem, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact), + ); + } + } + + /// Processes starred two-qubit replacement branches by replaying the exact + /// omitted-gate branch against detector/observable metadata. + fn process_two_qubit_exact_replacement_branches_source_tracked( + &self, + loc1: usize, + loc2: usize, + dem: &mut DetectorErrorModel, + ) { + let Some(weights) = &self.noise.p2_weights else { + return; + }; + if !weights.has_replacement_entries() { + return; + } + + let context = self + .exact_branch_context + .expect("exact_branch_replay was validated with circuit context"); + let loc1_meta = &self.influence_map.locations[loc1]; + let loc2_meta = &self.influence_map.locations[loc2]; + let request = ExactBranchReplayRequest { + gate_node: loc1_meta.node, + gate_type: loc1_meta.gate_type, + loc_indices: [loc1, loc2], + }; + + for (replacement_pauli, relative_probability) in weights.replacement_entries() { + if *relative_probability <= 0.0 { + continue; + } + let label = two_qubit_label_for_replay(replacement_pauli) + .expect("exact_branch_replay replacement Pauli was validated"); + let (p1, p2) = two_qubit_label_to_pauli_indices(&label) + .expect("exact_branch_replay label must be a two-qubit Pauli"); + let analysis = self + .exact_branch_analysis(context, request) + .expect("exact_branch_replay effect was validated"); + let base_effect = analysis.base_effect.clone(); + let branch_pauli_effect = analysis.branch_effects[p1 as usize][p2 as usize].clone(); + let effect = base_effect.xor(&branch_pauli_effect); + if effect.is_empty() { + continue; + } + + let source_locations = [loc1, loc2]; + let source_paulis = [Pauli::from_u8(p1), Pauli::from_u8(p2)]; + let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; + let source_before_flags = [loc1_meta.before, loc2_meta.before]; + dem.add_direct_contribution_with_source_components( + effect, + self.noise.p2 * *relative_probability, + SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationExactReplacementBranch) + .with_replacement_branch(), + DirectSourceComponents::new(&base_effect, &branch_pauli_effect), + ); + } + } + /// Processes a measurement fault with source tracking. fn process_meas_fault_source_tracked( &self, @@ -846,11 +1707,147 @@ impl<'a> DemBuilder<'a> { self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); if !mechanism.is_empty() { dem.add_direct_contribution_with_source( - mechanism, - p, - SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]), + mechanism, + p, + SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]), + ); + } + } + + /// Processes local measurement-crosstalk payloads when hidden outcomes are + /// deterministic and state-independent. + fn process_measurement_crosstalk_source_tracked( + &self, + loc_idx: usize, + payload_rate: f64, + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let (bit_flip_probability, leak_probability) = + match self.noise.measurement_crosstalk_dem_mode { + MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing => ( + 0.5 * (self.noise.p_meas_crosstalk_model.p_0_to_1 + + self.noise.p_meas_crosstalk_model.p_1_to_0), + 0.5 * (self.noise.p_meas_crosstalk_model.p_0_to_leak + + self.noise.p_meas_crosstalk_model.p_1_to_leak), + ), + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing => { + let context = self.exact_branch_context.expect( + "measurement crosstalk exact deterministic mode was validated with context", + ); + let loc = &self.influence_map.locations[loc_idx]; + let hidden = Self::hidden_mz_result_before_crosstalk_payload(context, loc) + .expect( + "measurement crosstalk exact deterministic hidden result was validated", + ); + if hidden.flip { + ( + self.noise.p_meas_crosstalk_model.p_1_to_0, + self.noise.p_meas_crosstalk_model.p_1_to_leak, + ) + } else { + ( + self.noise.p_meas_crosstalk_model.p_0_to_1, + self.noise.p_meas_crosstalk_model.p_0_to_leak, + ) + } + } + MeasurementCrosstalkDemMode::Omitted => { + return; + } + }; + if matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) { + let leak_pauli_probability = leak_probability / 4.0; + self.process_measurement_crosstalk_pauli_rates_source_tracked( + loc_idx, + [ + payload_rate * (bit_flip_probability + leak_pauli_probability), + payload_rate * leak_pauli_probability, + payload_rate * leak_pauli_probability, + ], + dem, + meas_to_detectors, + meas_to_observables, + ); + } else { + self.process_measurement_crosstalk_pauli_rates_source_tracked( + loc_idx, + [payload_rate * bit_flip_probability, 0.0, 0.0], + dem, + meas_to_detectors, + meas_to_observables, + ); + } + } + + /// Processes local measurement-crosstalk payloads as single-location Pauli + /// source channels while preserving crosstalk source metadata. + fn process_measurement_crosstalk_pauli_rates_source_tracked( + &self, + loc_idx: usize, + rates: [f64; 3], + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let loc = &self.influence_map.locations[loc_idx]; + let [rate_x, rate_y, rate_z] = rates; + let effect = |pauli| -> FaultMechanism { + if loc.gate_type == GateType::MeasCrosstalkGlobalPayload { + let context = self.exact_branch_context.expect( + "measurement crosstalk exact deterministic mode was validated with context", + ); + self.exact_measurement_crosstalk_pauli_effect(context, loc, pauli) + .expect("global measurement crosstalk exact replay was validated") + } else { + self.compute_mechanism(loc_idx, pauli, meas_to_detectors, meas_to_observables) + } + }; + let x_effect = effect(Pauli::X); + let z_effect = effect(Pauli::Z); + + if rate_x > 0.0 && !x_effect.is_empty() { + dem.add_direct_contribution_with_source( + x_effect.clone(), + rate_x, + SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } + if rate_z > 0.0 && !z_effect.is_empty() { + dem.add_direct_contribution_with_source( + z_effect.clone(), + rate_z, + SourceMetadata::new(&[loc_idx], &[Pauli::Z], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), ); } + + let y_effect = x_effect.xor(&z_effect); + if rate_y > 0.0 && !y_effect.is_empty() { + if !x_effect.is_empty() && !z_effect.is_empty() { + dem.add_y_decomposed_contribution_with_source( + &x_effect, + &z_effect, + rate_y, + SourceMetadata::new(&[loc_idx], &[Pauli::Y], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } else { + dem.add_direct_contribution_with_source( + y_effect, + rate_y, + SourceMetadata::new(&[loc_idx], &[Pauli::Y], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } + } } /// Processes a single-qubit gate fault with source tracking. @@ -944,19 +1941,47 @@ impl<'a> DemBuilder<'a> { let loc1_meta = &self.influence_map.locations[loc1]; let loc2_meta = &self.influence_map.locations[loc2]; - // Compute base effects for X and Z on each qubit + let effects = + self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); + + // Process all 15 non-trivial Pauli combinations + for p1 in 0u8..4 { + for p2 in 0u8..4 { + if p1 == 0 && p2 == 0 { + continue; // Skip II + } + + // Per-pair rate: index = 4*p1 + p2 - 1 (skipping II at idx 0). + let flat = 4 * (p1 as usize) + (p2 as usize); + let prob = rates[flat - 1]; + if prob == 0.0 { + continue; + } + Self::add_two_qubit_pauli_contribution( + loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, None, + ); + } + } + } + + fn two_qubit_effect_table( + &self, + loc1: usize, + loc2: usize, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) -> [[FaultMechanism; 4]; 4] { let x1 = self.compute_mechanism(loc1, Pauli::X, meas_to_detectors, meas_to_observables); let z1 = self.compute_mechanism(loc1, Pauli::Z, meas_to_detectors, meas_to_observables); let x2 = self.compute_mechanism(loc2, Pauli::X, meas_to_detectors, meas_to_observables); let z2 = self.compute_mechanism(loc2, Pauli::Z, meas_to_detectors, meas_to_observables); - // Build effect table for all 16 Pauli combinations let get_single_effect = |p: u8, x: &FaultMechanism, z: &FaultMechanism| -> FaultMechanism { match p { - 0 => FaultMechanism::new(), // I - 1 => x.clone(), // X - 2 => x.xor(z), // Y = X XOR Z - 3 => z.clone(), // Z + 0 => FaultMechanism::new(), + 1 => x.clone(), + 2 => x.xor(z), + 3 => z.clone(), _ => unreachable!("Pauli index must be 0-3"), } }; @@ -969,82 +1994,196 @@ impl<'a> DemBuilder<'a> { effects[p1 as usize][p2 as usize] = e1.xor(&e2); } } + effects + } - // Process all 15 non-trivial Pauli combinations - for p1 in 0u8..4 { - for p2 in 0u8..4 { - if p1 == 0 && p2 == 0 { - continue; // Skip II - } + fn two_qubit_effect_table_for_map( + influence_map: &DagFaultInfluenceMap, + loc1: usize, + loc2: usize, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) -> [[FaultMechanism; 4]; 4] { + let x1 = Self::compute_mechanism_for_map( + influence_map, + loc1, + Pauli::X, + meas_to_detectors, + meas_to_observables, + ); + let z1 = Self::compute_mechanism_for_map( + influence_map, + loc1, + Pauli::Z, + meas_to_detectors, + meas_to_observables, + ); + let x2 = Self::compute_mechanism_for_map( + influence_map, + loc2, + Pauli::X, + meas_to_detectors, + meas_to_observables, + ); + let z2 = Self::compute_mechanism_for_map( + influence_map, + loc2, + Pauli::Z, + meas_to_detectors, + meas_to_observables, + ); - let effect = &effects[p1 as usize][p2 as usize]; - if effect.is_empty() { - continue; - } + let get_single_effect = |p: u8, x: &FaultMechanism, z: &FaultMechanism| -> FaultMechanism { + match p { + 0 => FaultMechanism::new(), + 1 => x.clone(), + 2 => x.xor(z), + 3 => z.clone(), + _ => unreachable!("Pauli index must be 0-3"), + } + }; - // 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; - } + let mut effects: [[FaultMechanism; 4]; 4] = Default::default(); + for p1 in 0..4u8 { + for p2 in 0..4u8 { + let e1 = get_single_effect(p1, &x1, &z1); + let e2 = get_single_effect(p2, &x2, &z2); + effects[p1 as usize][p2 as usize] = e1.xor(&e2); + } + } + effects + } - // Get component effects (P1I and IP2) - let e1 = &effects[p1 as usize][0]; // P1 on qubit 1, I on qubit 2 - let e2 = &effects[0][p2 as usize]; // I on qubit 1, P2 on qubit 2 - - // Check if this is a "graphlike decomposable" source: - // - Combined effect has exactly 2 detectors and no dem_outputs - // - Both component effects are non-empty - // - Both component effects are graphlike (≤2 detectors) - let graphlike_decomposable = effect.num_detectors() == 2 - && effect.dem_outputs.is_empty() - && !e1.is_empty() - && !e2.is_empty() - && e1.num_detectors() <= 2 - && e2.num_detectors() <= 2; - if graphlike_decomposable { - dem.mark_graphlike_decomposable(effect.detectors[0], effect.detectors[1]); - } + #[allow(clippy::too_many_arguments)] + fn add_two_qubit_pauli_contribution( + loc1: usize, + loc2: usize, + p1: u8, + p2: u8, + prob: f64, + effects: &[[FaultMechanism; 4]; 4], + loc1_meta: &DagSpacetimeLocation, + loc2_meta: &DagSpacetimeLocation, + dem: &mut DetectorErrorModel, + direct_source_family: Option, + ) { + let effect = &effects[p1 as usize][p2 as usize]; + if effect.is_empty() { + return; + } - // Check for intra-channel decomposition (Y-containing cases) - if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { - // Y-containing channels can be decomposable if both their X and Z - // components have non-empty, distinct effects. Otherwise they - // produce the effect directly without decomposition. - let e_a = &effects[a1 as usize][a2 as usize]; - let e_b = &effects[b1 as usize][b2 as usize]; - - // Only truly decomposable if both components are non-empty and different. - // add_y_decomposed_contribution handles routing to Direct when appropriate. - dem.add_y_decomposed_contribution_with_source( - e_a, - e_b, - prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), - ); - } else { - // Non-Y channel (XI, IX, ZI, IZ, XX, XZ, ZX, ZZ) - // These are always direct sources. - dem.add_direct_contribution_with_source_components( - effect.clone(), - prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), - DirectSourceComponents::new(e1, e2), - ); - } + let e1 = &effects[p1 as usize][0]; + let e2 = &effects[0][p2 as usize]; + + let graphlike_decomposable = effect.num_detectors() == 2 + && effect.dem_outputs.is_empty() + && !e1.is_empty() + && !e2.is_empty() + && e1.num_detectors() <= 2 + && e2.num_detectors() <= 2; + if graphlike_decomposable { + dem.mark_graphlike_decomposable(effect.detectors[0], effect.detectors[1]); + } + + let source_locations = [loc1, loc2]; + let source_paulis = [Pauli::from_u8(p1), Pauli::from_u8(p2)]; + let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; + let source_before_flags = [loc1_meta.before, loc2_meta.before]; + + let source_frame_components = if direct_source_family.is_none() { + Self::two_qubit_clifford_source_frame_components(loc1_meta.gate_type, p1, p2, effects) + } else { + None + }; + if let Some(parts) = source_frame_components.as_ref() { + dem.add_direct_contribution_with_source_components( + effect.clone(), + prob, + SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ), + DirectSourceComponents::from_slice(parts.as_slice()), + ); + return; + } + + if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { + let e_a = &effects[a1 as usize][a2 as usize]; + let e_b = &effects[b1 as usize][b2 as usize]; + let mut source = SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ); + if direct_source_family.is_some() { + source = source.with_replacement_branch(); + } + dem.add_y_decomposed_contribution_with_source(e_a, e_b, prob, source); + } else { + let mut source = SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ); + if let Some(family) = direct_source_family { + source = source + .with_direct_source_family(family) + .with_replacement_branch(); } + + dem.add_direct_contribution_with_source_components( + effect.clone(), + prob, + source, + DirectSourceComponents::new(e1, e2), + ); + } + } + + /// Builds exact source-frame components for ordinary post-gate Pauli noise + /// on supported two-qubit Clifford gates. + /// + /// A post-gate Pauli can be pulled back through the Clifford into a pre-gate + /// Pauli. Decomposing that pre-gate Pauli into X/Z generators often exposes + /// the graphlike source pieces that were hidden by the native gate frame. + /// Each generator is then pushed forward again and looked up in the existing + /// post-gate effect table, so the XOR of returned components is exactly the + /// original post-gate effect. + fn two_qubit_clifford_source_frame_components( + gate_type: GateType, + post_p1: u8, + post_p2: u8, + effects: &[[FaultMechanism; 4]; 4], + ) -> Option> { + let images = two_qubit_pre_generator_post_images(gate_type)?; + let (pre_p1, pre_p2) = invert_two_qubit_clifford_post_pauli(images, (post_p1, post_p2))?; + + let mut components = SmallVec::new(); + for image in two_qubit_pre_pauli_generator_images(images, pre_p1, pre_p2) { + toggle_source_component( + &mut components, + effects[image.0 as usize][image.1 as usize].clone(), + ); + } + + if components.is_empty() { + return None; + } + + #[cfg(debug_assertions)] + { + let combined = components + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + debug_assert_eq!(combined, effects[post_p1 as usize][post_p2 as usize]); } + + Some(components) } /// Builds mappings from measurement indices to detector/DEM-output IDs. @@ -1175,27 +2314,35 @@ impl<'a> DemBuilder<'a> { pauli: Pauli, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, + ) -> FaultMechanism { + Self::compute_mechanism_for_map( + self.influence_map, + loc_idx, + pauli, + meas_to_detectors, + meas_to_observables, + ) + } + + fn compute_mechanism_for_map( + influence_map: &DagFaultInfluenceMap, + loc_idx: usize, + pauli: Pauli, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, ) -> FaultMechanism { // Get the measurement indices that this fault flips - let rust_dets = self - .influence_map - .get_detector_indices(loc_idx, pauli.as_u8()); + let rust_dets = influence_map.get_detector_indices(loc_idx, pauli.as_u8()); // Convert to pre-defined detector IDs using XOR let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); let mut triggered_tracked_paulis: SmallVec<[u32; 2]> = SmallVec::new(); - for dem_output_idx in self - .influence_map - .get_observable_indices(loc_idx, pauli.as_u8()) - { + for dem_output_idx in influence_map.get_observable_indices(loc_idx, pauli.as_u8()) { xor_toggle_2(&mut triggered_obs, dem_output_idx); } - for tracked_pauli_idx in self - .influence_map - .get_tracked_pauli_indices(loc_idx, pauli.as_u8()) - { + for tracked_pauli_idx in influence_map.get_tracked_pauli_indices(loc_idx, pauli.as_u8()) { xor_toggle_2(&mut triggered_tracked_paulis, tracked_pauli_idx); } @@ -1268,6 +2415,52 @@ fn pauli_pair_for_weight(p1: usize, p2: usize) -> pecos_core::PauliString { pecos_core::PauliString::with_phase_and_paulis(pecos_core::QuarterPhase::PlusOne, paulis) } +fn two_qubit_label_to_pauli_indices(label: &str) -> Option<(u8, u8)> { + let mut chars = label.chars(); + let p1 = pauli_label_to_index(chars.next()?)?; + let p2 = pauli_label_to_index(chars.next()?)?; + chars.next().is_none().then_some((p1, p2)) +} + +fn two_qubit_label_for_replay(pauli: &pecos_core::PauliString) -> Result { + let mut label = String::with_capacity(2); + for qubit in [0, 1] { + label.push(pauli_index_to_label(match pauli.get(qubit) { + pecos_core::Pauli::I => 0, + pecos_core::Pauli::X => 1, + pecos_core::Pauli::Y => 2, + pecos_core::Pauli::Z => 3, + })); + } + if pauli.qubits().into_iter().any(|qubit| qubit > 1) { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay replacement Pauli {} is not supported by the two-qubit replay path", + pauli.to_sparse_str() + ))); + } + Ok(label) +} + +fn pauli_index_to_label(index: u8) -> char { + match index { + 0 => 'I', + 1 => 'X', + 2 => 'Y', + 3 => 'Z', + _ => unreachable!("Pauli index must be 0-3"), + } +} + +fn pauli_label_to_index(label: char) -> Option { + match label { + 'I' => Some(0), + 'X' => Some(1), + 'Y' => Some(2), + 'Z' => Some(3), + _ => None, + } +} + /// Computes the per-error probability for independent error channels. /// /// For a depolarizing channel with total error probability `p` split among `n` @@ -1305,6 +2498,133 @@ fn per_channel_probability(total_prob: f64, num_channels: u32) -> f64 { // Intra-Channel Decomposition // ============================================================================ +type TwoQubitPauli = (u8, u8); +type TwoQubitGeneratorImages = [TwoQubitPauli; 4]; + +/// Returns post-gate images of the pre-gate generators +/// `[X1, Z1, X2, Z2]`, ignoring phase. +#[inline] +fn two_qubit_pre_generator_post_images(gate_type: GateType) -> Option { + match gate_type { + GateType::CX => Some([ + (1, 1), // X1 -> XX + (3, 0), // Z1 -> ZI + (0, 1), // X2 -> IX + (3, 3), // Z2 -> ZZ + ]), + GateType::CZ => Some([ + (1, 3), // X1 -> XZ + (3, 0), // Z1 -> ZI + (3, 1), // X2 -> ZX + (0, 3), // Z2 -> IZ + ]), + GateType::SZZ | GateType::SZZdg => Some([ + (2, 3), // X1 -> YZ + (3, 0), // Z1 -> ZI + (3, 2), // X2 -> ZY + (0, 3), // Z2 -> IZ + ]), + _ => None, + } +} + +#[inline] +fn invert_two_qubit_clifford_post_pauli( + images: TwoQubitGeneratorImages, + post: TwoQubitPauli, +) -> Option { + for pre_p1 in 0..4 { + for pre_p2 in 0..4 { + if forward_two_qubit_pauli(images, pre_p1, pre_p2) == post { + return Some((pre_p1, pre_p2)); + } + } + } + None +} + +fn two_qubit_pre_pauli_generator_images( + images: TwoQubitGeneratorImages, + pre_p1: u8, + pre_p2: u8, +) -> SmallVec<[TwoQubitPauli; 4]> { + let mut out = SmallVec::new(); + if pauli_has_x(pre_p1) { + out.push(images[0]); + } + if pauli_has_z(pre_p1) { + out.push(images[1]); + } + if pauli_has_x(pre_p2) { + out.push(images[2]); + } + if pauli_has_z(pre_p2) { + out.push(images[3]); + } + out +} + +#[inline] +fn forward_two_qubit_pauli( + images: TwoQubitGeneratorImages, + pre_p1: u8, + pre_p2: u8, +) -> TwoQubitPauli { + two_qubit_pre_pauli_generator_images(images, pre_p1, pre_p2) + .into_iter() + .fold((0, 0), xor_two_qubit_pauli) +} + +#[inline] +fn xor_two_qubit_pauli(a: TwoQubitPauli, b: TwoQubitPauli) -> TwoQubitPauli { + (xor_pauli(a.0, b.0), xor_pauli(a.1, b.1)) +} + +#[inline] +fn xor_pauli(a: u8, b: u8) -> u8 { + pauli_from_bits( + pauli_has_x(a) ^ pauli_has_x(b), + pauli_has_z(a) ^ pauli_has_z(b), + ) +} + +#[inline] +fn pauli_has_x(pauli: u8) -> bool { + matches!(pauli, 1 | 2) +} + +#[inline] +fn pauli_has_z(pauli: u8) -> bool { + matches!(pauli, 2 | 3) +} + +#[inline] +fn pauli_from_bits(has_x: bool, has_z: bool) -> u8 { + match (has_x, has_z) { + (false, false) => 0, + (true, false) => 1, + (true, true) => 2, + (false, true) => 3, + } +} + +fn toggle_source_component( + components: &mut SmallVec<[FaultMechanism; 4]>, + component: FaultMechanism, +) { + if component.is_empty() { + return; + } + if let Some(index) = components + .iter() + .position(|existing| existing == &component) + { + components.remove(index); + } else { + components.push(component); + } +} + /// Returns the intra-channel decomposition for Y-containing Pauli cases. /// /// For any two-qubit Pauli case (p1, p2) that contains Y, returns the @@ -1748,6 +3068,208 @@ fn extract_measurement_refs( Ok((records, meas_ids)) } +fn is_two_qubit_noise_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::CX + | GateType::CZ + | GateType::CY + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SWAP + | GateType::RXX + | GateType::RYY + | GateType::RZZ + ) +} + +fn two_qubit_after_location_pairs(locations: &[DagSpacetimeLocation]) -> Vec<[usize; 2]> { + let mut groups: BTreeMap> = BTreeMap::new(); + for (loc_idx, loc) in locations.iter().enumerate() { + if is_two_qubit_noise_gate(loc.gate_type) && !loc.before { + groups.entry(loc.node).or_default().push(loc_idx); + } + } + + groups + .into_values() + .flat_map(|loc_indices| { + loc_indices + .chunks_exact(2) + .map(|pair| [pair[0], pair[1]]) + .collect::>() + }) + .collect() +} + +impl ExactBranchReplayContext<'_> { + fn replacement_branch_requests( + self, + locations: &[DagSpacetimeLocation], + ) -> Result, DemBuilderError> { + let mut requests = Vec::new(); + for [loc1_idx, loc2_idx] in two_qubit_after_location_pairs(locations) { + let loc1 = &locations[loc1_idx]; + let loc2 = &locations[loc2_idx]; + if loc1.node != loc2.node { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected paired two-qubit locations to share a node, got {} and {}", + loc1.node, loc2.node + ))); + } + if loc1.gate_type != loc2.gate_type { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected paired two-qubit locations at node {} to share a gate type, got {:?} and {:?}", + loc1.node, loc1.gate_type, loc2.gate_type + ))); + } + let replacement = self.circuit.gate(loc1.node).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected an original gate at node {}", + loc1.node + )) + })?; + if !loc1 + .qubits + .iter() + .chain(loc2.qubits.iter()) + .all(|qubit| replacement.qubits.contains(qubit)) + { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay location qubits at node {} are not all present in the omitted branch gate", + loc1.node + ))); + } + requests.push(ExactBranchReplayRequest { + gate_node: loc1.node, + gate_type: loc1.gate_type, + loc_indices: [loc1_idx, loc2_idx], + }); + } + Ok(requests) + } + + #[cfg(test)] + fn omitted_branch_location_pair( + self, + request: ExactBranchReplayRequest, + original_locations: &[DagSpacetimeLocation], + ) -> Result<[usize; 2], DemBuilderError> { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + + let branch = circuit_with_omitted_two_qubit_gate(self.circuit, request.gate_node)?; + let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); + identity_location_pair_for_request(request, original_locations, &branch_map.locations) + } +} + +fn omitted_branch_flips_measurement_parity_from_histories( + request: ExactBranchReplayRequest, + ideal_history: &MeasurementHistory, + branch_history: &MeasurementHistory, + measurement_indices: &[usize], +) -> Result { + measurement_parity_differs_from_histories( + ideal_history, + branch_history, + measurement_indices, + &format!( + "exact_branch_replay omitted gate at node {}", + request.gate_node + ), + ) +} + +fn measurement_parity_differs_from_histories( + ideal_history: &MeasurementHistory, + branch_history: &MeasurementHistory, + measurement_indices: &[usize], + context: &str, +) -> Result { + let ideal = measurement_parity_expression(ideal_history, measurement_indices, "ideal")?; + let branch = measurement_parity_expression(branch_history, measurement_indices, "branch")?; + if ideal.dependencies != branch.dependencies { + return Err(DemBuilderError::ConfigurationError(format!( + "{context} changes measurement dependencies for parity {measurement_indices:?}; this branch is not representable as a single deterministic DEM event" + ))); + } + Ok(ideal.flip ^ branch.flip) +} + +fn measurement_parity_expression( + history: &MeasurementHistory, + measurement_indices: &[usize], + history_label: &str, +) -> Result { + let mut dependencies = BitSet::new(); + let mut flip = false; + + for &measurement_idx in measurement_indices { + let result = history.get(measurement_idx).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay {history_label} history has no measurement {measurement_idx}" + )) + })?; + dependencies.symmetric_difference_update(&result.outcome); + flip ^= result.flip; + } + + Ok(MeasurementParityExpression { dependencies, flip }) +} + +fn identity_location_pair_for_request( + request: ExactBranchReplayRequest, + original_locations: &[DagSpacetimeLocation], + branch_locations: &[DagSpacetimeLocation], +) -> Result<[usize; 2], DemBuilderError> { + let [orig_loc1, orig_loc2] = request.loc_indices; + let expected_qubits = [ + *original_locations + .get(orig_loc1) + .and_then(|loc| loc.qubits.first()) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay original location {orig_loc1} has no qubit" + )) + })?, + *original_locations + .get(orig_loc2) + .and_then(|loc| loc.qubits.first()) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay original location {orig_loc2} has no qubit" + )) + })?, + ]; + + let mut pair = [usize::MAX; 2]; + for (branch_idx, branch_loc) in branch_locations.iter().enumerate() { + if branch_loc.node != request.gate_node + || branch_loc.before + || branch_loc.gate_type != GateType::I + { + continue; + } + if branch_loc.qubits.first() == Some(&expected_qubits[0]) { + pair[0] = branch_idx; + } else if branch_loc.qubits.first() == Some(&expected_qubits[1]) { + pair[1] = branch_idx; + } + } + + if pair.contains(&usize::MAX) { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay could not find identity branch locations for omitted gate node {} on qubits {:?}", + request.gate_node, expected_qubits + ))); + } + Ok(pair) +} + // ============================================================================ // Convenience: build DEM from circuit (free function to handle lifetimes) // ============================================================================ @@ -1757,10 +3279,7 @@ fn extract_measurement_refs( /// Reads detector/DEM output definitions from circuit metadata attributes. fn build_dem_from_circuit( circuit: &pecos_quantum::DagCircuit, - p1: f64, - p2: f64, - p_meas: f64, - p_prep: f64, + noise: NoiseConfig, ) -> Result { use crate::fault_tolerance::influence_builder::InfluenceBuilder; use crate::fault_tolerance::propagator::DagFaultAnalyzer; @@ -1796,7 +3315,9 @@ fn build_dem_from_circuit( } }); - let builder = DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep); + let builder = DemBuilder::new(&influence_map) + .with_noise_config(noise) + .with_exact_branch_replay_context(circuit); let builder = if let Some(ref dj) = det_json { builder.with_detectors_json(dj)? @@ -1820,7 +3341,36 @@ fn build_dem_from_circuit( builder }; - builder.try_build() + builder.try_build() +} + +/// Return a branch circuit where one ideal two-qubit gate has been omitted. +/// +/// Replacement-branch exact replay needs to evaluate "the hardware branch did +/// not apply this entangler" without disturbing the surrounding DAG wiring. +/// Replacing the selected node by batched identities preserves the node id, +/// qubit wires, and topological context while making the operation itself a +/// no-op on every qubit carried by the original gate. +fn circuit_with_omitted_two_qubit_gate( + circuit: &pecos_quantum::DagCircuit, + node: usize, +) -> Result { + let original = circuit.gate(node).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "cannot omit gate at node {node}: no such gate node exists" + )) + })?; + if !original.gate_type.is_two_qubit() { + return Err(DemBuilderError::ConfigurationError(format!( + "cannot omit gate at node {node}: {:?} is not a two-qubit gate", + original.gate_type + ))); + } + + let mut branch = circuit.clone(); + let replacement = pecos_core::Gate::simple(GateType::I, original.qubits.clone()); + *branch.gate_mut(node).expect("gate existed before clone") = replacement; + Ok(branch) } fn observable_records_from_annotations( @@ -2014,12 +3564,15 @@ pub fn resolve_result_tags( pub enum DemBuilderError { /// JSON parsing error. ParseError(String), + /// Invalid DEM builder configuration. + ConfigurationError(String), } impl std::fmt::Display for DemBuilderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::ParseError(msg) => write!(f, "DEM builder parse error: {msg}"), + Self::ConfigurationError(msg) => write!(f, "DEM builder configuration error: {msg}"), } } } @@ -2030,6 +3583,47 @@ impl std::error::Error for DemBuilderError {} mod tests { use super::*; + #[test] + fn test_szz_source_frame_components_pull_post_error_to_pre_generators() { + fn dets(indices: &[u32]) -> FaultMechanism { + FaultMechanism::from_unsorted(indices.iter().copied(), std::iter::empty()) + } + + let a = dets(&[0, 1]); + let b = dets(&[2]); + let c = dets(&[3, 4]); + + let mut effects: [[FaultMechanism; 4]; 4] = Default::default(); + effects[2][3] = a.clone(); // SZZ maps pre X1 to post YZ. + effects[3][0] = b.clone(); // SZZ maps pre Z1 to post ZI. + effects[0][3] = c.clone(); // SZZ maps pre Z2 to post IZ. + effects[1][0] = a.xor(&b).xor(&c); + + let parts = + DemBuilder::two_qubit_clifford_source_frame_components(GateType::SZZ, 1, 0, &effects) + .expect("post XI should pull back through SZZ to pre YZ"); + + assert_eq!(parts.len(), 3); + assert!(parts.contains(&a)); + assert!(parts.contains(&b)); + assert!(parts.contains(&c)); + assert_eq!( + parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)), + effects[1][0] + ); + + effects[3][0] = a.clone(); + effects[1][0] = c.clone(); + + let parts = + DemBuilder::two_qubit_clifford_source_frame_components(GateType::SZZ, 1, 0, &effects) + .expect("duplicate source components should cancel by XOR"); + + assert_eq!(parts.as_slice(), &[c]); + } + #[test] fn test_from_circuit_tracks_tracked_pauli() { use pecos_core::pauli::X; @@ -2636,6 +4230,646 @@ mod tests { ); } + #[test] + fn test_try_build_rejects_exact_branch_replay_without_provider() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::pauli::Z; + + let influence_map = DagFaultInfluenceMap::with_capacity(0); + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement([], [(Z(0) & Z(1), 1.0)])) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let err = DemBuilder::new(&influence_map) + .with_noise_config(noise) + .try_build() + .expect_err("exact branch replay must fail loud without an exact provider"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("circuit-aware exact branch provider"), + "unexpected error: {err}", + ); + } + + #[test] + fn test_from_circuit_exact_branch_replay_emits_omitted_gate_effect() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::PauliString; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement( + [], + [(PauliString::identity(), 1.0)], + )) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("exact branch replay should emit representable branch effects"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); + assert!((contributions[0].probability - 0.01).abs() < 1.0e-12); + assert!(contributions[0].replacement_branch); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::TwoLocationExactReplacementBranch) + ); + assert!( + contributions[0] + .paulis + .iter() + .all(|pauli| *pauli == Pauli::I), + "omission-only replacement branch should be recorded as *II" + ); + let (base_effect, branch_pauli_effect) = contributions[0] + .direct_component_effects() + .expect("exact branch replay should preserve base/branch components"); + assert_eq!( + base_effect.xor(&branch_pauli_effect), + contributions[0].effect + ); + assert_eq!(base_effect.detectors.as_slice(), &[0]); + assert!(branch_pauli_effect.is_empty()); + } + + fn single_qubit_local_crosstalk_circuit(pre_payload_h: bool) -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + if pre_payload_h { + circuit.add_gate_auto_wire(Gate::h(&[QubitId(0)])); + } + circuit.add_gate_auto_wire(Gate::meas_crosstalk_local_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + + fn single_qubit_no_crosstalk_payload_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + + fn two_qubit_global_crosstalk_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::meas_crosstalk_global_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + + fn two_qubit_global_crosstalk_random_victim_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::h(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::meas_crosstalk_global_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_emits_dem_source() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic local measurement crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); + assert!((contributions[0].probability - 0.1).abs() < 1.0e-12); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::MeasurementCrosstalk) + ); + assert_eq!( + contributions[0].source_gate_types.as_slice(), + &[GateType::MeasCrosstalkLocalPayload] + ); + assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); + } + + #[test] + fn test_exact_deterministic_global_measurement_crosstalk_emits_victim_dem_source() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = two_qubit_global_crosstalk_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic global measurement crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); + assert!((contributions[0].probability - 0.1).abs() < 1.0e-12); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::MeasurementCrosstalk) + ); + assert_eq!( + contributions[0].source_gate_types.as_slice(), + &[GateType::MeasCrosstalkGlobalPayload] + ); + assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_requires_payloads() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_no_crosstalk_payload_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("positive local crosstalk rate without payloads must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("no MeasCrosstalkLocalPayload locations"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_exact_deterministic_global_measurement_crosstalk_requires_payloads() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_no_crosstalk_payload_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("positive global crosstalk rate without payloads must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("no MeasCrosstalkGlobalPayload locations"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_identity_transition_is_empty() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.0, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("identity local measurement crosstalk should be representable"); + + assert_eq!(dem.num_contributions(), 0); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_rejects_hidden_randomness() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(true); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("nondeterministic hidden measurement must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("state-independent hidden MZ result"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_averaged_global_measurement_crosstalk_accepts_hidden_randomness() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = two_qubit_global_crosstalk_random_victim_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode( + MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing, + ); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("averaged global leak2depolar crosstalk should handle random hidden MZ"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!( + contributions.len(), + 2, + "all effects:\n{}", + dem.all_contribution_effects() + ); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::X] + && (contribution.probability - 0.05625).abs() < 1.0e-12 + })); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::Y] + && (contribution.probability - 0.00625).abs() < 1.0e-12 + })); + assert!(contributions.iter().all(|contribution| { + contribution.direct_source_family == Some(DirectSourceFamily::MeasurementCrosstalk) + && contribution.source_gate_types.as_slice() + == [GateType::MeasCrosstalkGlobalPayload] + })); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_rejects_leakage() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("plain exact deterministic crosstalk should reject leakage"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string().contains("leakage transitions"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_leakage_as_depolarizing() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode( + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing, + ); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic leak2depolar local crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!(contributions.len(), 2); + let total_probability: f64 = contributions + .iter() + .map(|contribution| contribution.probability) + .sum(); + assert!((total_probability - 0.125).abs() < 1.0e-12); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::X] + && (contribution.probability - 0.1125).abs() < 1.0e-12 + })); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::Y] + && (contribution.probability - 0.0125).abs() < 1.0e-12 + })); + assert!(contributions.iter().all(|contribution| { + contribution.source_gate_types.as_slice() == [GateType::MeasCrosstalkLocalPayload] + })); + } + + #[test] + fn test_from_circuit_exact_branch_replay_skips_empty_replacement_effect() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::pauli::X; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement([], [(X(1), 1.0)])) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("exact branch replay should allow empty representable effects"); + + assert_eq!(dem.num_contributions(), 0); + } + + #[test] + fn test_from_circuit_exact_branch_replay_rejects_dependency_changing_branch() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::PauliString; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.h(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement( + [], + [(PauliString::identity(), 1.0)], + )) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("dependency-changing replacement branches must stay fail-loud"); + + assert!( + err.to_string().contains("not representable"), + "unexpected error: {err}", + ); + } + + #[test] + fn test_circuit_with_omitted_two_qubit_gate_preserves_wiring() { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + let prep0 = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + let prep1 = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + let entangler = circuit.add_gate_auto_wire(Gate::szz(&[(QubitId(0), QubitId(1))])); + let meas0 = circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + let meas1 = circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + + let branch = circuit_with_omitted_two_qubit_gate(&circuit, entangler) + .expect("two-qubit entangler can be omitted"); + + assert_eq!(circuit.gate(entangler).unwrap().gate_type, GateType::SZZ); + let replacement = branch.gate(entangler).unwrap(); + assert_eq!(replacement.gate_type, GateType::I); + assert_eq!(replacement.qubits.as_slice(), &[QubitId(0), QubitId(1)]); + + assert_eq!( + branch.predecessor_on_qubit(entangler, QubitId(0)), + Some(prep0) + ); + assert_eq!( + branch.predecessor_on_qubit(entangler, QubitId(1)), + Some(prep1) + ); + assert_eq!( + branch.successor_on_qubit(entangler, QubitId(0)), + Some(meas0) + ); + assert_eq!( + branch.successor_on_qubit(entangler, QubitId(1)), + Some(meas1) + ); + assert_eq!(branch.topological_order(), circuit.topological_order()); + } + + #[test] + fn test_exact_branch_replay_context_collects_two_qubit_requests() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + let entangler = circuit.add_gate_auto_wire(Gate::szz(&[(QubitId(0), QubitId(1))])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let requests = ExactBranchReplayContext { circuit: &circuit } + .replacement_branch_requests(&influence_map.locations) + .expect("two-qubit branch requests should be recoverable"); + + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].gate_node, entangler); + assert_eq!(requests[0].gate_type, GateType::SZZ); + let branch_pair = (ExactBranchReplayContext { circuit: &circuit }) + .omitted_branch_location_pair(requests[0], &influence_map.locations) + .expect("omitted branch identity locations should be recoverable"); + assert_ne!(branch_pair[0], branch_pair[1]); + + let loc_qubits: Vec<_> = requests[0] + .loc_indices + .iter() + .flat_map(|&idx| influence_map.locations[idx].qubits.iter().copied()) + .collect(); + assert_eq!(loc_qubits, vec![QubitId(0), QubitId(1)]); + } + + #[test] + fn test_exact_branch_replay_base_effect_detects_deterministic_omitted_gate_flip() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + let entangler = circuit.add_gate_auto_wire(pecos_core::Gate::cx(&[(0, 1)])); + circuit.mz(&[1]); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let context = ExactBranchReplayContext { circuit: &circuit }; + let request = context + .replacement_branch_requests(&influence_map.locations) + .unwrap() + .into_iter() + .find(|request| request.gate_node == entangler) + .expect("CX request should be present"); + + let builder = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id":0,"records":[-1]}]"#) + .unwrap() + .with_num_measurements(1); + let effect = builder + .exact_omitted_branch_base_effect(context, request) + .expect("omitting this CX only flips a deterministic measurement parity"); + + assert_eq!(effect.detectors.as_slice(), &[0]); + assert!(effect.dem_outputs.is_empty()); + } + + #[test] + fn test_exact_branch_replay_replacement_pauli_combines_with_omitted_gate_effect() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + let entangler = circuit.add_gate_auto_wire(pecos_core::Gate::cx(&[(0, 1)])); + circuit.mz(&[1]); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let context = ExactBranchReplayContext { circuit: &circuit }; + let request = context + .replacement_branch_requests(&influence_map.locations) + .unwrap() + .into_iter() + .find(|request| request.gate_node == entangler) + .expect("CX request should be present"); + + let builder = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id":0,"records":[-1]}]"#) + .unwrap() + .with_num_measurements(1); + + let omitted_only = builder + .exact_replacement_branch_effect(context, request, "II") + .expect("omission-only branch should be deterministic here"); + assert_eq!(omitted_only.detectors.as_slice(), &[0]); + assert!(builder.exact_ideal_history_cache.borrow().is_some()); + assert_eq!(builder.exact_branch_cache.borrow().len(), 1); + + let omitted_then_target_x = builder + .exact_replacement_branch_effect(context, request, "IX") + .expect("replacement X on the target should be deterministic here"); + assert!( + omitted_then_target_x.is_empty(), + "target X after omitted CX restores the ideal target measurement" + ); + assert_eq!(builder.exact_branch_cache.borrow().len(), 1); + + let builder = builder.with_detectors_json("[]").unwrap(); + assert!(builder.exact_branch_cache.borrow().is_empty()); + } + + #[test] + fn test_circuit_with_omitted_two_qubit_gate_rejects_bad_nodes() { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + let prep = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + + assert!(matches!( + circuit_with_omitted_two_qubit_gate(&circuit, prep), + Err(DemBuilderError::ConfigurationError(_)) + )); + assert!(matches!( + circuit_with_omitted_two_qubit_gate(&circuit, prep + 1), + Err(DemBuilderError::ConfigurationError(_)) + )); + } + #[test] fn test_parse_accepts_dem_label_id_form() { let det = parse_detectors_json(r#"[{"id": "D0", "records": [-1]}]"#).unwrap(); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index 72c102c43..8f90260c1 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -71,7 +71,10 @@ use smallvec::SmallVec; use std::collections::{BTreeMap, BTreeSet}; use wide::u64x4; -use super::types::{NoiseConfig, PerGateTypeNoise, combine_probabilities}; +use super::types::{ + NoiseConfig, PauliWeights, PerGateTypeNoise, ReplacementBranchApproximation, + combine_probabilities, +}; // ============================================================================ // DEM Mechanism (used during building) @@ -423,10 +426,8 @@ impl SamplingEngine { .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(1, |l| l.idle_duration.max(1)); - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - Some(noise.idle_pauli_probs(duration as f64)) + .map_or(0.0, |l| l.idle_duration.max(0.0)); + Some(noise.idle_pauli_probs(duration)) } else { None }; @@ -463,7 +464,18 @@ impl SamplingEngine { // Custom per-Pauli weights: p * weight_for(pauli) events .iter() - .map(|event| p * weights.weight_for(&event.pauli)) + .map(|event| { + let weight = if n_qubits == 2 { + weights.two_qubit_weight_for( + loc.gate_type, + &event.pauli, + noise.p2_replacement_approximation, + ) + } else { + weights.weight_for(&event.pauli) + }; + p * weight + }) .collect() } else { // Default uniform: p / num_events @@ -1835,9 +1847,14 @@ pub(crate) struct SamplingEngineBuilder<'a> { per_gate: Option, influence_map: &'a DagFaultInfluenceMap, p1: f64, + p1_gate_rates: BTreeMap, p2: f64, + p2_gate_rates: BTreeMap, p_meas: f64, p_prep: f64, + p1_weights: Option, + p2_weights: Option, + p2_replacement_approximation: ReplacementBranchApproximation, idle_noise: Option, detector_records: Vec>, observable_records: Vec>, @@ -1858,9 +1875,14 @@ impl<'a> SamplingEngineBuilder<'a> { Self { influence_map, p1: 0.01, + p1_gate_rates: BTreeMap::new(), p2: 0.01, + p2_gate_rates: BTreeMap::new(), p_meas: 0.01, p_prep: 0.01, + p1_weights: None, + p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_noise: None, per_gate: None, detector_records: Vec::new(), @@ -1874,9 +1896,31 @@ impl<'a> SamplingEngineBuilder<'a> { #[must_use] pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { self.p1 = p1; + self.p1_gate_rates.clear(); self.p2 = p2; + self.p2_gate_rates.clear(); self.p_meas = p_meas; self.p_prep = p_prep; + self.p1_weights = None; + self.p2_weights = None; + self.p2_replacement_approximation = ReplacementBranchApproximation::default(); + self.idle_noise = None; + self + } + + /// Set the full noise model, including biased Pauli weights. + #[must_use] + pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { + self.p1 = noise.p1; + self.p1_gate_rates = noise.p1_gate_rates.clone(); + self.p2 = noise.p2; + self.p2_gate_rates = noise.p2_gate_rates.clone(); + self.p_meas = noise.p_meas; + self.p_prep = noise.p_prep; + self.p1_weights.clone_from(&noise.p1_weights); + self.p2_weights.clone_from(&noise.p2_weights); + self.p2_replacement_approximation = noise.p2_replacement_approximation; + self.idle_noise = noise.uses_dedicated_idle_noise().then_some(noise); self } @@ -1964,6 +2008,16 @@ impl<'a> SamplingEngineBuilder<'a> { /// Build the [`SamplingEngine`]. #[must_use] pub fn build(self) -> SamplingEngine { + if self.p2_replacement_approximation == ReplacementBranchApproximation::ExactBranchReplay + && self + .p2_weights + .as_ref() + .is_some_and(super::types::PauliWeights::has_replacement_entries) + { + panic!( + "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" + ); + } let num_detectors = self.detector_records.len(); let influence_observable_ids = self.influence_map.observable_ids(); let num_influence_observables = self.influence_map.num_observables(); @@ -2088,7 +2142,9 @@ impl<'a> SamplingEngineBuilder<'a> { } // Process two-qubit gates as pairs - let has_any_2q_noise = self.per_gate.is_some() || self.p2 > 0.0; + let has_any_2q_noise = self.per_gate.is_some() + || self.p2 > 0.0 + || self.p2_gate_rates.values().any(|rate| *rate > 0.0); if has_any_2q_noise { for loc_indices in cx_groups.values() { for pair in loc_indices.chunks(2) { @@ -2279,7 +2335,16 @@ impl<'a> SamplingEngineBuilder<'a> { ] } } else { - [self.p1 / 3.0; 3] + let p1_total = self.p1_gate_rates.get(&gate).copied().unwrap_or(self.p1); + if let Some(weights) = &self.p1_weights { + use pecos_core::pauli::{X, Y, Z}; + return [ + p1_total * weights.weight_for(&X(0)), + p1_total * weights.weight_for(&Y(0)), + p1_total * weights.weight_for(&Z(0)), + ]; + } + [p1_total / 3.0; 3] } } @@ -2298,8 +2363,7 @@ impl<'a> SamplingEngineBuilder<'a> { return rates; } if pg.base.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = pg.base.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -2309,8 +2373,7 @@ impl<'a> SamplingEngineBuilder<'a> { if let Some(noise) = &self.idle_noise && noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = noise.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -2328,7 +2391,21 @@ impl<'a> SamplingEngineBuilder<'a> { std::array::from_fn(|i| pg.rate_2q(gate, i)) } } else { - [self.p2 / 15.0; 15] + let p2_total = self.p2_gate_rates.get(&gate).copied().unwrap_or(self.p2); + if let Some(weights) = &self.p2_weights { + return std::array::from_fn(|idx| { + let flat = idx + 1; + let p1 = flat / 4; + let p2 = flat % 4; + p2_total + * weights.two_qubit_weight_for( + gate, + &pauli_pair_for_weight(p1, p2), + self.p2_replacement_approximation, + ) + }); + } + [p2_total / 15.0; 15] } } @@ -2536,6 +2613,26 @@ where } } +fn pauli_pair_for_weight(p1: usize, p2: usize) -> pecos_core::PauliString { + let mut paulis = Vec::new(); + let pauli_from_index = |idx| match idx { + 0 => pecos_core::Pauli::I, + 1 => pecos_core::Pauli::X, + 2 => pecos_core::Pauli::Y, + 3 => pecos_core::Pauli::Z, + _ => unreachable!("Pauli index must be 0-3"), + }; + let pa1 = pauli_from_index(p1); + let pa2 = pauli_from_index(p2); + if pa1 != pecos_core::Pauli::I { + paulis.push((pa1, pecos_core::QubitId::from(0usize))); + } + if pa2 != pecos_core::Pauli::I { + paulis.push((pa2, pecos_core::QubitId::from(1usize))); + } + pecos_core::PauliString::with_phase_and_paulis(pecos_core::QuarterPhase::PlusOne, paulis) +} + /// XORs two [`DemMechanism`]s (symmetric difference of detectors and standard observables). fn xor_mechanisms(a: Option<&DemMechanism>, b: Option<&DemMechanism>) -> DemMechanism { match (a, b) { diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs index d18e60b01..67b5f7e72 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs @@ -19,8 +19,10 @@ //! //! - Two DEMs are equivalent if they produce the same probability distribution //! over (`detector_events`, `dem_output_flips`) patterns. -//! - Decomposed DEMs (using ^) create independent error channels that are `XORed`. -//! - Different decomposition strategies can produce equivalent sampling results. +//! - Decomposed DEMs (using ^) split one correlated error mechanism into +//! graphlike components that are `XORed` when that mechanism fires. +//! - Different decomposition strategies can produce equivalent sampling results +//! when their correlated mechanisms have the same combined effects. //! - For non-decomposed DEMs, mechanisms must match exactly. //! //! # Comparison Methods diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs index 85176ec4c..545f709a0 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs @@ -128,8 +128,7 @@ impl<'a> MemBuilder<'a> { } GateType::Idle if !loc.before => { if self.noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = self.noise.idle_pauli_probs(duration); if probs.px > 0.0 { self.process_single_pauli_fault(loc_idx, Pauli::X, probs.px, &mut mem); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 53d29c1c5..54579e70b 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -39,6 +39,7 @@ use super::dem_sampler::SamplingEngine; use super::types::{DemOutput, NoiseConfig, PerGateTypeNoise}; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, DemOutputKind}; use pecos_core::prelude::GateType; +use pecos_decoder_core::obs_mask::ObsMask; use pecos_num::z2_linalg::z2_rank_from_records; use pecos_random::RngProbabilityExt; use rand_core::Rng; @@ -697,28 +698,38 @@ impl DemSampler { /// Bit mask selecting observable outputs. /// - /// Existing decoder APIs use `u64` observable masks, so outputs with index - /// \>= 64 are not representable here and are ignored consistently with the - /// existing mask-based paths. + /// Wide bitmask of which DEM outputs are decoder-facing observables. + /// + /// Returns an [`ObsMask`], so more than 64 observables are represented with + /// no truncation. Compute this once, up front, and pass it to + /// [`Self::observable_mask_from_dem_output_flips`] per shot. #[must_use] - pub fn observable_dem_output_mask(&self) -> u64 { - self.observable_ids() - .into_iter() - .filter(|&idx| idx < u64::BITS as usize) - .fold(0u64, |acc, idx| acc | (1u64 << idx)) + pub fn observable_dem_output_mask(&self) -> ObsMask { + let mut mask = ObsMask::new(); + for idx in self.observable_ids() { + mask.set(idx); + } + mask } - /// Converts a sampled DEM-output flip vector into an observable-only mask. + /// Converts a sampled DEM-output flip vector into an observable-only wide mask. + /// + /// `observable_mask` is the value returned by + /// [`Self::observable_dem_output_mask`]; pass it in so the per-shot path does + /// not recompute it. #[must_use] - pub fn observable_mask_from_dem_output_flips(&self, flips: &[bool]) -> 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)) + pub fn observable_mask_from_dem_output_flips( + &self, + flips: &[bool], + observable_mask: &ObsMask, + ) -> ObsMask { + let mut mask = ObsMask::new(); + for (idx, flipped) in flips.iter().enumerate() { + if *flipped && observable_mask.get(idx) { + mask.set(idx); + } + } + mask } /// Number of mechanisms in the sampler. @@ -1351,19 +1362,12 @@ impl<'a> DemSamplerBuilder<'a> { } let mut builder = SamplingEngineBuilder::new(self.influence_map) - .with_noise( - self.noise.p1, - self.noise.p2, - self.noise.p_meas, - self.noise.p_prep, - ) + .with_noise_config(self.noise.clone()) .with_detector_records(detector_records) .with_observable_records(observable_records.clone()); if let Some(per_gate) = self.per_gate { builder = builder.with_per_gate_noise(per_gate); - } else if self.noise.uses_dedicated_idle_noise() { - builder = builder.with_idle_noise_config(self.noise.clone()); } if let Some(order) = self.measurement_order { @@ -1448,18 +1452,16 @@ pub(crate) fn compute_location_probs_from_noise( | GateType::SWAP | GateType::RXX | GateType::RYY - | GateType::RZZ => noise.p2, + | GateType::RZZ => noise.p2_rate_for_gate(loc.gate_type), GateType::Idle => { if noise.uses_dedicated_idle_noise() { - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); noise.idle_pauli_probs(duration).total() } else { 0.0 } } - _ => noise.p1, + _ => noise.p1_rate_for_gate(loc.gate_type), } }) .collect() @@ -1876,6 +1878,44 @@ mod tests { assert_eq!(sampler.sample(&mut rng), (vec![false], vec![true])); } + #[test] + fn observable_dem_output_mask_supports_above_64_observables() { + // >64 observables are now represented in a wide ObsMask with no + // truncation (the old u64 cap is lifted; observable 64 is present). + use super::super::builder::DemBuilder; + use pecos_quantum::Attribute; + + let n: usize = 65; + let mut circuit = DagCircuit::new(); + for i in 0..n { + circuit.pz(&[i]); + } + for i in 0..n { + circuit.mz(&[i]); + } + circuit.set_attr("num_measurements", Attribute::String(n.to_string())); + let n_i64 = i64::try_from(n).unwrap(); + let obs: Vec = (0..n) + .map(|i| { + let rec = i64::try_from(i).unwrap() - n_i64; + format!(r#"{{"id":{i},"records":[{rec}]}}"#) + }) + .collect(); + circuit.set_attr( + "observables", + Attribute::String(format!("[{}]", obs.join(","))), + ); + + let dem = DemBuilder::from_circuit(&circuit, 0.03, 0.0, 0.02, 0.0); + let sampler = DemSampler::from_detector_error_model(&dem); + assert_eq!(sampler.num_dem_outputs(), n); + + let mask = sampler.observable_dem_output_mask(); + assert_eq!(mask.count_ones(), u32::try_from(n).unwrap()); + assert!(mask.get(64), "observable 64 must be representable"); + assert_eq!(mask.to_u64(), None, "65 observables do not fit a u64"); + } + #[test] fn raw_mode_without_dem_outputs_reports_zero_dem_outputs() { let mut circuit = DagCircuit::new(); @@ -1923,9 +1963,17 @@ mod tests { .num_tracked_paulis(), 1 ); - assert_eq!(sampler.observable_dem_output_mask(), 1); - assert_eq!(sampler.observable_mask_from_dem_output_flips(&[false]), 0); - assert_eq!(sampler.observable_mask_from_dem_output_flips(&[true]), 1); + let obs_mask = sampler.observable_dem_output_mask(); + assert_eq!(obs_mask, ObsMask::from_u64(1)); + assert!( + sampler + .observable_mask_from_dem_output_flips(&[false], &obs_mask) + .is_zero() + ); + assert_eq!( + sampler.observable_mask_from_dem_output_flips(&[true], &obs_mask), + ObsMask::from_u64(1) + ); } #[test] diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 3deda7745..703f6fb04 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -39,10 +39,11 @@ //! - [`DetectorErrorModel::to_string()`] - Non-decomposed format. Each //! mechanism is output once with its combined probability. //! -//! - [`DetectorErrorModel::to_string_decomposed()`] - Decomposed format. -//! Hyperedge errors (3+ detectors) are decomposed into graphlike components, -//! and 2-detector mechanisms may have multiple representations for decoder -//! compatibility. +//! - [`DetectorErrorModel::to_string_decomposed()`] - Source-decomposed format. +//! Faults are decomposed only using component structure carried by the source +//! contribution itself. Residual hyperedges stay hyperedges so hypergraph +//! decoders see the faithful model and graphlike decoders can reject them +//! loudly. //! //! Decomposed errors use the `^` separator to indicate XOR composition: //! @@ -50,15 +51,16 @@ //! error(0.01) D0 D1 ^ D2 D3 //! ``` //! -//! This indicates an error decomposed into two parts whose XOR equals the -//! original mechanism. +//! This indicates one correlated source event decomposed into two source +//! components whose XOR equals the original mechanism. Components may still be +//! hyperedges if the physical source component flips 3+ detectors. use pecos_core::PauliString; use pecos_core::gate_type::GateType; use rand::RngExt; use smallvec::SmallVec; use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt; use std::hash::{Hash, Hasher}; @@ -126,6 +128,15 @@ pub enum DirectSourceFamily { /// Two-location direct source where exactly one component is non-empty. TwoLocationOneSidedComponent, + /// Two-location direct source produced by a replacement-branch projection. + TwoLocationReplacementBranchImpact, + + /// Two-location direct source produced by exact replacement-branch replay. + TwoLocationExactReplacementBranch, + + /// Single-location direct source produced by measurement-crosstalk replay. + MeasurementCrosstalk, + /// Fallback for other direct-source shapes. Other, } @@ -162,12 +173,23 @@ pub struct FaultContribution { /// Coarse direct-source family for read-only analysis. pub direct_source_family: Option, - /// Optional per-location component effects for direct multi-location sources. + /// Optional legacy per-location component effects for direct multi-location sources. /// /// These are builder-time component effects whose XOR equals `effect`. They are - /// currently recorded for direct two-qubit channel sources to aid decomposition - /// analysis without changing emitted DEM behavior. + /// kept for the original two-component diagnostics and Python bindings. pub direct_component_effects: Option<(FaultMechanism, FaultMechanism)>, + + /// Optional source-frame component effects for direct multi-location sources. + /// + /// These are builder-time component effects whose XOR equals `effect`. Unlike + /// `direct_component_effects`, this can carry more than two pieces, which is + /// needed when a native two-qubit Clifford's exact source frame is generated + /// by multiple Pauli generators. + pub source_component_effects: Option>, + + /// True when this contribution came from a replacement branch rather than + /// ordinary post-gate Pauli noise. + pub replacement_branch: bool, } #[derive(Debug, Clone, Copy)] @@ -176,6 +198,8 @@ pub(crate) struct SourceMetadata<'a, Index> { paulis: &'a [Pauli], gate_types: &'a [GateType], before_flags: &'a [bool], + direct_source_family_override: Option, + replacement_branch: bool, } impl<'a, Index> SourceMetadata<'a, Index> { @@ -190,19 +214,38 @@ impl<'a, Index> SourceMetadata<'a, Index> { paulis, gate_types, before_flags, + direct_source_family_override: None, + replacement_branch: false, } } + + pub(crate) const fn with_direct_source_family(mut self, family: DirectSourceFamily) -> Self { + self.direct_source_family_override = Some(family); + self + } + + pub(crate) const fn with_replacement_branch(mut self) -> Self { + self.replacement_branch = true; + self + } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct DirectSourceComponents<'a> { - first: &'a FaultMechanism, - second: &'a FaultMechanism, + components: SmallVec<[&'a FaultMechanism; 4]>, } impl<'a> DirectSourceComponents<'a> { - pub(crate) const fn new(first: &'a FaultMechanism, second: &'a FaultMechanism) -> Self { - Self { first, second } + pub(crate) fn new(first: &'a FaultMechanism, second: &'a FaultMechanism) -> Self { + Self { + components: smallvec::smallvec![first, second], + } + } + + pub(crate) fn from_slice(components: &'a [FaultMechanism]) -> Self { + Self { + components: components.iter().collect(), + } } } @@ -210,7 +253,7 @@ impl FaultContribution { fn classify_direct_source_family( location_indices: &[u32], paulis: &[Pauli], - direct_component_effects: Option<(&FaultMechanism, &FaultMechanism)>, + direct_component_effects: Option<&[&FaultMechanism]>, ) -> Option { if location_indices.is_empty() { return None; @@ -225,8 +268,12 @@ impl FaultContribution { DirectSourceFamily::SingleLocation }), 2 => { - if let Some((first, second)) = direct_component_effects { - if first.is_empty() ^ second.is_empty() { + if let Some(components) = direct_component_effects { + let non_empty = components + .iter() + .filter(|component| !component.is_empty()) + .count(); + if components.len() == 2 && non_empty == 1 { Some(DirectSourceFamily::TwoLocationOneSidedComponent) } else { Some(DirectSourceFamily::TwoLocationComponent) @@ -254,6 +301,8 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, + replacement_branch: false, } } @@ -275,12 +324,12 @@ impl FaultContribution { paulis: source.paulis.iter().copied().collect(), source_gate_types: source.gate_types.iter().copied().collect(), source_before_flags: source.before_flags.iter().copied().collect(), - direct_source_family: Self::classify_direct_source_family( - source.location_indices, - source.paulis, - None, - ), + direct_source_family: source.direct_source_family_override.or_else(|| { + Self::classify_direct_source_family(source.location_indices, source.paulis, None) + }), direct_component_effects: None, + source_component_effects: None, + replacement_branch: source.replacement_branch, } } @@ -296,13 +345,29 @@ impl FaultContribution { debug_assert_eq!(source.location_indices.len(), source.paulis.len()); debug_assert_eq!(source.location_indices.len(), source.gate_types.len()); debug_assert_eq!(source.location_indices.len(), source.before_flags.len()); - let source_type = if (components.first == &effect && components.second.is_empty()) - || (components.second == &effect && components.first.is_empty()) - { + // This constructor takes ownership of its inputs (like `effect`/`source`); + // unpack the component view into the underlying slice of references. + let DirectSourceComponents { components } = components; + let component_refs = components.as_slice(); + let non_empty_components: SmallVec<[&FaultMechanism; 4]> = component_refs + .iter() + .copied() + .filter(|component| !component.is_empty()) + .collect(); + let source_type = if non_empty_components.len() == 1 && non_empty_components[0] == &effect { FaultSourceType::DirectOneSidedComponent } else { FaultSourceType::Direct }; + let direct_component_effects = if let [first, second] = component_refs { + Some(((*first).clone(), (*second).clone())) + } else { + None + }; + let source_component_effects = component_refs + .iter() + .map(|component| (*component).clone()) + .collect(); Self { effect, probability, @@ -311,12 +376,16 @@ impl FaultContribution { paulis: source.paulis.iter().copied().collect(), source_gate_types: source.gate_types.iter().copied().collect(), source_before_flags: source.before_flags.iter().copied().collect(), - direct_source_family: Self::classify_direct_source_family( - source.location_indices, - source.paulis, - Some((components.first, components.second)), - ), - direct_component_effects: Some((components.first.clone(), components.second.clone())), + direct_source_family: source.direct_source_family_override.or_else(|| { + Self::classify_direct_source_family( + source.location_indices, + source.paulis, + Some(component_refs), + ) + }), + direct_component_effects, + source_component_effects: Some(source_component_effects), + replacement_branch: source.replacement_branch, } } @@ -346,6 +415,8 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, + replacement_branch: false, } } @@ -376,6 +447,8 @@ impl FaultContribution { source_before_flags: source.before_flags.iter().copied().collect(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, + replacement_branch: source.replacement_branch, } } @@ -411,6 +484,12 @@ impl FaultContribution { pub fn direct_component_effects(&self) -> Option<(FaultMechanism, FaultMechanism)> { self.direct_component_effects.clone() } + + /// Returns source-frame component effects for a direct multi-location source. + #[must_use] + pub fn source_component_effects(&self) -> Option> { + self.source_component_effects.clone() + } } /// Aggregated source-tracked information for one unique effect. @@ -506,6 +585,22 @@ pub enum TwoDetectorDirectRenderPolicy { PreferRecordedComponents, } +/// Policy for decomposing hyperedges in decomposed DEM output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum HyperedgeDecompositionRenderPolicy { + /// Preserve source-carried components exactly, even when a component is a + /// hyperedge. This is the faithful source-component view. + PreserveSourceComponents, + + /// Decompose hyperedge components using graphlike pieces learned from + /// source-carried components and full source-tracked alternatives. + SourceGraphlikeComponents, + + /// Historical compatibility mode: search graphlike full effects elsewhere + /// in the DEM. This is not source proof and is kept explicit. + GlobalGraphlikeSearch, +} + // ============================================================================ // Error Mechanism // ============================================================================ @@ -898,15 +993,120 @@ fn find_hyperedge_decomposition( GraphlikeDecompositionIndex::new(graphlike_set).find_hyperedge_decomposition(hyperedge) } +const GRAPH_PATH_BOUNDARY: GraphPathNode = GraphPathNode::Boundary; +const MAX_GRAPH_PATH_TERMINALS: usize = 12; +const MAX_GRAPH_PATH_LENGTH: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum GraphPathNode { + Boundary, + Detector(u32), +} + +#[derive(Debug, Clone)] +struct GraphPathEdge { + next: GraphPathNode, + mechanism: FaultMechanism, +} + +#[derive(Debug, Clone)] +struct GraphPathCandidate { + dem_outputs: SmallVec<[u32; 2]>, + parts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct GraphPathSearchCacheKey { + start: GraphPathNode, + end: GraphPathNode, + excluded_origin: Option, +} + +impl GraphPathSearchCacheKey { + fn new(a: GraphPathNode, b: GraphPathNode, excluded_origin: Option<&FaultMechanism>) -> Self { + let (start, end) = ordered_graph_path_pair(a, b); + Self { + start, + end, + excluded_origin: excluded_origin.cloned(), + } + } +} + +type GraphPathSearchCache = BTreeMap>; + +#[derive(Clone, Default)] +struct SourceGraphlikeClosure { + mechanisms: BTreeSet, + primitive: BTreeSet, + derived_origins: BTreeMap>, +} + +impl SourceGraphlikeClosure { + fn from_primitives(primitives: BTreeSet) -> Self { + Self { + mechanisms: primitives.clone(), + primitive: primitives, + derived_origins: BTreeMap::new(), + } + } + + fn len(&self) -> usize { + self.mechanisms.len() + } + + fn insert_derived(&mut self, mechanism: FaultMechanism, origin: &FaultMechanism) { + let is_new = self.mechanisms.insert(mechanism.clone()); + if is_new && !self.primitive.contains(&mechanism) { + self.derived_origins + .entry(mechanism) + .or_default() + .insert(origin.clone()); + } + } +} + struct GraphlikeDecompositionIndex { graphlike_set: BTreeSet, + primitive_graphlike_set: BTreeSet, + derived_origins: BTreeMap>, /// Indexed by detector ID; see `SingletonDecompositionIndex` for the same /// pattern and rationale. Detector IDs are dense `0..num_detectors`. candidates_by_detector: Vec>, + /// Maps one- and two-detector symptoms to a known graphlike mechanism with + /// those symptoms, including any associated logical frame changes. + symptoms: BTreeMap, Vec>, + /// Source-derived detector graph used for exact path decompositions. + /// + /// A one-detector mechanism is represented as an edge to `Boundary`; a + /// two-detector mechanism is represented as an ordinary detector edge. + /// Paths may introduce intermediate detector vertices only when they cancel + /// pairwise in the XOR of path components. + path_adjacency: BTreeMap>, } impl GraphlikeDecompositionIndex { fn new(graphlike_set: &BTreeSet) -> Self { + Self::from_parts( + graphlike_set.clone(), + graphlike_set.clone(), + BTreeMap::new(), + ) + } + + fn from_source_closure(closure: &SourceGraphlikeClosure) -> Self { + Self::from_parts( + closure.mechanisms.clone(), + closure.primitive.clone(), + closure.derived_origins.clone(), + ) + } + + fn from_parts( + graphlike_set: BTreeSet, + primitive_graphlike_set: BTreeSet, + derived_origins: BTreeMap>, + ) -> Self { let max_det = graphlike_set .iter() .flat_map(|c| c.detectors.iter().copied()) @@ -915,11 +1115,58 @@ impl GraphlikeDecompositionIndex { let mut candidates_by_detector: Vec> = max_det.map_or_else(Vec::new, |m| vec![Vec::new(); m as usize + 1]); - for candidate in graphlike_set { + for candidate in &graphlike_set { for &det in &candidate.detectors { candidates_by_detector[det as usize].push(candidate.clone()); } } + let mut symptoms: BTreeMap, Vec> = BTreeMap::new(); + for candidate in &graphlike_set { + if candidate.detectors.is_empty() || candidate.detectors.len() > 2 { + continue; + } + symptoms + .entry(candidate.detectors.clone()) + .or_default() + .push(candidate.clone()); + } + for values in symptoms.values_mut() { + values.sort_by(Self::compare_symptom_candidates); + values.dedup(); + } + let mut path_adjacency: BTreeMap> = BTreeMap::new(); + for candidate in &graphlike_set { + if !is_detectable_graphlike_component(candidate) || candidate.is_standard_empty() { + continue; + } + match candidate.detectors.as_slice() { + [det] => { + Self::add_path_edge( + &mut path_adjacency, + GRAPH_PATH_BOUNDARY, + GraphPathNode::Detector(*det), + candidate.clone(), + ); + } + [d0, d1] => { + Self::add_path_edge( + &mut path_adjacency, + GraphPathNode::Detector(*d0), + GraphPathNode::Detector(*d1), + candidate.clone(), + ); + } + _ => {} + } + } + for edges in path_adjacency.values_mut() { + edges.sort_by(|a, b| { + a.next + .cmp(&b.next) + .then_with(|| a.mechanism.cmp(&b.mechanism)) + }); + edges.dedup_by(|a, b| a.next == b.next && a.mechanism == b.mechanism); + } for values in &mut candidates_by_detector { values.sort_by(|a, b| { b.detectors @@ -929,17 +1176,83 @@ impl GraphlikeDecompositionIndex { }); } Self { - graphlike_set: graphlike_set.clone(), + graphlike_set, + primitive_graphlike_set, + derived_origins, candidates_by_detector, + symptoms, + path_adjacency, + } + } + + fn add_path_edge( + path_adjacency: &mut BTreeMap>, + a: GraphPathNode, + b: GraphPathNode, + mechanism: FaultMechanism, + ) { + path_adjacency.entry(a).or_default().push(GraphPathEdge { + next: b, + mechanism: mechanism.clone(), + }); + path_adjacency + .entry(b) + .or_default() + .push(GraphPathEdge { next: a, mechanism }); + } + + fn compare_symptom_candidates(a: &FaultMechanism, b: &FaultMechanism) -> Ordering { + a.dem_outputs + .len() + .cmp(&b.dem_outputs.len()) + .then_with(|| a.dem_outputs.cmp(&b.dem_outputs)) + .then_with(|| a.cmp(b)) + } + + fn candidate_allowed( + &self, + candidate: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> bool { + let Some(origin) = excluded_origin else { + return true; + }; + if self.primitive_graphlike_set.contains(candidate) { + return true; } + self.derived_origins.get(candidate).is_none_or(|origins| { + origins + .iter() + .any(|candidate_origin| candidate_origin != origin) + }) + } + + fn first_allowed_symptom_candidate( + &self, + key: &SmallVec<[u32; 4]>, + excluded_origin: Option<&FaultMechanism>, + ) -> Option<&FaultMechanism> { + self.symptoms.get(key).and_then(|candidates| { + candidates + .iter() + .find(|candidate| self.candidate_allowed(candidate, excluded_origin)) + }) } fn find_hyperedge_decomposition( &self, hyperedge: &FaultMechanism, + ) -> Option> { + self.find_hyperedge_decomposition_for_origin(hyperedge, None) + } + + fn find_hyperedge_decomposition_for_origin( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, ) -> Option> { // If already graphlike, no decomposition needed - if hyperedge.is_graphlike() { + if is_detectable_graphlike_component(hyperedge) { return Some(vec![hyperedge.clone()]); } @@ -954,13 +1267,342 @@ impl GraphlikeDecompositionIndex { }; let mut memo = BTreeMap::new(); - let result = self.search_decomposition(hyperedge, &mut memo); + let result = self.search_decomposition(hyperedge, excluded_origin, &mut memo); result.filter(|decomp| decomp_dets_valid(decomp)) } + fn find_hyperedge_decomposition_with_remnants_for_origin_cached( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + ) -> Option> { + if let Some(decomp) = + self.find_hyperedge_decomposition_for_origin(hyperedge, excluded_origin) + { + return Some(decomp); + } + + let remnant = self.find_remnant_decomposition(hyperedge, excluded_origin); + if remnant + .as_ref() + .is_some_and(|parts| self.remnant_decomposition_is_preferred(parts, excluded_origin)) + { + return remnant; + } + + self.find_graph_path_decomposition_with_cache(hyperedge, excluded_origin, path_cache) + .or(remnant) + } + + fn find_hyperedge_discovery_decomposition_for_origin( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + self.find_hyperedge_decomposition_for_origin(hyperedge, excluded_origin) + .or_else(|| self.find_remnant_decomposition(hyperedge, excluded_origin)) + } + + fn remnant_decomposition_is_preferred( + &self, + parts: &[FaultMechanism], + excluded_origin: Option<&FaultMechanism>, + ) -> bool { + parts.iter().all(|part| { + is_detectable_graphlike_component(part) + && self.graphlike_set.contains(part) + && self.candidate_allowed(part, excluded_origin) + }) + } + + #[cfg(test)] + fn find_graph_path_decomposition( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + let mut path_cache = GraphPathSearchCache::new(); + self.find_graph_path_decomposition_with_cache(hyperedge, excluded_origin, &mut path_cache) + } + + fn find_graph_path_decomposition_with_cache( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + ) -> Option> { + if is_detectable_graphlike_component(hyperedge) { + return Some(vec![hyperedge.clone()]); + } + if self.path_adjacency.is_empty() || hyperedge.detectors.len() > MAX_GRAPH_PATH_TERMINALS { + return None; + } + + let mut terminals: Vec = hyperedge + .detectors + .iter() + .copied() + .map(GraphPathNode::Detector) + .collect(); + if terminals.len() % 2 == 1 { + terminals.push(GRAPH_PATH_BOUNDARY); + } + + let full_mask = (1u16 << terminals.len()) - 1; + let mut pairing_state_cache: BTreeMap< + u16, + BTreeMap, Vec>, + > = BTreeMap::new(); + let states = self.graph_path_pairing_states( + full_mask, + &terminals, + excluded_origin, + path_cache, + &mut pairing_state_cache, + ); + let mut best: Option> = None; + + for (outputs, mut parts) in states { + let missing_outputs = symmetric_difference_2(&outputs, &hyperedge.dem_outputs); + if !missing_outputs.is_empty() { + // Do not repair logical-frame parity by appending a pure `L` + // component. Although this preserves the full XOR effect, it + // creates an undetectable decomposition component (`L0 ^ D1 D2`) + // that graphlike decoders cannot attach to a syndrome edge. + // A valid graph-path decomposition must carry observable parity + // on one of its detector/boundary components. + continue; + } + parts = parity_reduce_mechanisms(parts); + if parts.is_empty() { + continue; + } + let recomposed = parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + if recomposed == *hyperedge && parts_are_detectable_graphlike(&parts) { + match best.as_ref() { + Some(existing) if !prefer_graph_path_parts(&parts, existing) => {} + _ => best = Some(parts), + } + } + } + + best + } + + fn graph_path_pairing_states( + &self, + mask: u16, + terminals: &[GraphPathNode], + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + pairing_state_cache: &mut BTreeMap, Vec>>, + ) -> BTreeMap, Vec> { + if let Some(cached) = pairing_state_cache.get(&mask) { + return cached.clone(); + } + + if mask == 0 { + let mut base = BTreeMap::new(); + base.insert(SmallVec::new(), Vec::new()); + pairing_state_cache.insert(mask, base.clone()); + return base; + } + + let first = mask.trailing_zeros() as usize; + let without_first = mask & !(1u16 << first); + let mut states: BTreeMap, Vec> = BTreeMap::new(); + + for second in (first + 1)..terminals.len() { + if without_first & (1u16 << second) == 0 { + continue; + } + + let tail_mask = without_first & !(1u16 << second); + let tail_states = self.graph_path_pairing_states( + tail_mask, + terminals, + excluded_origin, + path_cache, + pairing_state_cache, + ); + if tail_states.is_empty() { + continue; + } + + let candidates = path_cache + .entry(GraphPathSearchCacheKey::new( + terminals[first], + terminals[second], + excluded_origin, + )) + .or_insert_with(|| { + self.graph_path_candidates(terminals[first], terminals[second], excluded_origin) + }); + if candidates.is_empty() { + continue; + } + + for candidate in candidates.iter() { + for (tail_outputs, tail_parts) in &tail_states { + let outputs = symmetric_difference_2(&candidate.dem_outputs, tail_outputs); + let mut parts = Vec::with_capacity(candidate.parts.len() + tail_parts.len()); + parts.extend(candidate.parts.iter().cloned()); + parts.extend(tail_parts.iter().cloned()); + + match states.get(&outputs) { + Some(existing) if !prefer_graph_path_parts(&parts, existing) => {} + _ => { + states.insert(outputs, parts); + } + } + } + } + } + + pairing_state_cache.insert(mask, states.clone()); + states + } + + fn graph_path_candidates( + &self, + start: GraphPathNode, + end: GraphPathNode, + excluded_origin: Option<&FaultMechanism>, + ) -> Vec { + let mut queue = VecDeque::new(); + queue.push_back((start, SmallVec::<[u32; 2]>::new(), Vec::new())); + + let mut seen: BTreeSet<(GraphPathNode, SmallVec<[u32; 2]>)> = BTreeSet::new(); + seen.insert((start, SmallVec::new())); + let mut best_by_outputs: BTreeMap, Vec> = + BTreeMap::new(); + + while let Some((node, outputs, parts)) = queue.pop_front() { + if node == end && !parts.is_empty() { + best_by_outputs + .entry(outputs.clone()) + .and_modify(|existing| { + if prefer_graph_path_parts(&parts, existing) { + existing.clone_from(&parts); + } + }) + .or_insert_with(|| parts.clone()); + } + if parts.len() >= MAX_GRAPH_PATH_LENGTH { + continue; + } + + let Some(edges) = self.path_adjacency.get(&node) else { + continue; + }; + for edge in edges { + if !self.candidate_allowed(&edge.mechanism, excluded_origin) { + continue; + } + let next_outputs = symmetric_difference_2(&outputs, &edge.mechanism.dem_outputs); + let state = (edge.next, next_outputs.clone()); + if !seen.insert(state) { + continue; + } + + let mut next_parts = Vec::with_capacity(parts.len() + 1); + next_parts.extend(parts.iter().cloned()); + next_parts.push(edge.mechanism.clone()); + queue.push_back((edge.next, next_outputs, next_parts)); + } + } + + let mut candidates: Vec<_> = best_by_outputs + .into_iter() + .map(|(dem_outputs, parts)| GraphPathCandidate { dem_outputs, parts }) + .collect(); + candidates.sort_by(|a, b| { + a.parts + .len() + .cmp(&b.parts.len()) + .then_with(|| a.dem_outputs.cmp(&b.dem_outputs)) + .then_with(|| a.parts.cmp(&b.parts)) + }); + candidates + } + + fn find_remnant_decomposition( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + if is_detectable_graphlike_component(hyperedge) { + return Some(vec![hyperedge.clone()]); + } + + let mut done = BTreeSet::new(); + let mut remaining = hyperedge.clone(); + let mut parts = Vec::new(); + + for (i, &d0) in hyperedge.detectors.iter().enumerate() { + if done.contains(&d0) { + continue; + } + for &d1 in hyperedge.detectors.iter().skip(i + 1) { + if done.contains(&d1) { + continue; + } + let key = SmallVec::from_slice(&[d0, d1]); + let Some(candidate) = self.first_allowed_symptom_candidate(&key, excluded_origin) + else { + continue; + }; + done.insert(d0); + done.insert(d1); + remaining = remaining.xor(candidate); + parts.push(candidate.clone()); + break; + } + } + + for &det in &hyperedge.detectors { + if done.contains(&det) { + continue; + } + let key = SmallVec::from_slice(&[det]); + let Some(candidate) = self.first_allowed_symptom_candidate(&key, excluded_origin) + else { + continue; + }; + done.insert(det); + remaining = remaining.xor(candidate); + parts.push(candidate.clone()); + } + + let missed = hyperedge + .detectors + .iter() + .filter(|det| !done.contains(det)) + .count(); + if missed > 2 { + return None; + } + + if !remaining.is_standard_empty() { + parts.push(remaining); + } + let recomposed = parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + if recomposed == *hyperedge && parts_are_detectable_graphlike(&parts) { + Some(parts) + } else { + None + } + } + fn search_decomposition( &self, remaining: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, memo: &mut BTreeMap>>, ) -> Option> { if let Some(cached) = memo.get(remaining) { @@ -973,7 +1615,10 @@ impl GraphlikeDecompositionIndex { return result; } - if remaining.is_graphlike() && self.graphlike_set.contains(remaining) { + if is_detectable_graphlike_component(remaining) + && self.graphlike_set.contains(remaining) + && self.candidate_allowed(remaining, excluded_origin) + { let result = Some(vec![remaining.clone()]); memo.insert(remaining.clone(), result.clone()); return result; @@ -983,6 +1628,9 @@ impl GraphlikeDecompositionIndex { && let Some(candidates) = self.candidates_by_detector.get(pivot as usize) { for candidate in candidates { + if !self.candidate_allowed(candidate, excluded_origin) { + continue; + } if !candidate .detectors .iter() @@ -1001,7 +1649,7 @@ impl GraphlikeDecompositionIndex { continue; } - if let Some(suffix) = self.search_decomposition(&next, memo) { + if let Some(suffix) = self.search_decomposition(&next, excluded_origin, memo) { let mut combined = Vec::with_capacity(suffix.len() + 1); combined.push(candidate.clone()); combined.extend(suffix); @@ -1018,10 +1666,54 @@ impl GraphlikeDecompositionIndex { } } -/// Finds a decomposition of a graphlike effect into singleton detector components. -/// -/// This is used for "maximal" decomposition modes that prefer singleton -/// detector symptoms whenever the required singleton effects already exist as +fn ordered_graph_path_pair(a: GraphPathNode, b: GraphPathNode) -> (GraphPathNode, GraphPathNode) { + if a <= b { (a, b) } else { (b, a) } +} + +fn prefer_graph_path_parts(candidate: &[FaultMechanism], existing: &[FaultMechanism]) -> bool { + candidate + .len() + .cmp(&existing.len()) + .then_with(|| candidate.cmp(existing)) + == Ordering::Less +} + +fn parity_reduce_mechanisms(parts: Vec) -> Vec { + let mut reduced = Vec::new(); + for part in parts { + if part.is_empty() { + continue; + } + if let Some(index) = reduced.iter().position(|existing| existing == &part) { + reduced.remove(index); + } else { + reduced.push(part); + } + } + reduced.sort(); + reduced +} + +fn is_pure_logical_mechanism(part: &FaultMechanism) -> bool { + part.detectors.is_empty() && !part.dem_outputs.is_empty() +} + +fn is_detectable_graphlike_component(part: &FaultMechanism) -> bool { + part.num_detectors() <= 2 && !is_pure_logical_mechanism(part) +} + +fn parts_have_pure_logical_component(parts: &[FaultMechanism]) -> bool { + parts.iter().any(is_pure_logical_mechanism) +} + +fn parts_are_detectable_graphlike(parts: &[FaultMechanism]) -> bool { + parts.iter().all(is_detectable_graphlike_component) +} + +/// Finds a decomposition of a graphlike effect into singleton detector components. +/// +/// This is used for "maximal" decomposition modes that prefer singleton +/// detector symptoms whenever the required singleton effects already exist as /// standalone mechanisms in the DEM. fn find_singleton_decomposition( effect: &FaultMechanism, @@ -1031,6 +1723,9 @@ fn find_singleton_decomposition( return Some(Vec::new()); } if effect.num_detectors() <= 1 { + if is_pure_logical_mechanism(effect) { + return None; + } return Some(vec![effect.clone()]); } if index.is_empty() { @@ -1164,6 +1859,21 @@ fn convert_location_indices(location_indices: &[usize]) -> SmallVec<[u32; 2]> { .collect() } +fn converted_source_metadata<'a>( + source: SourceMetadata<'a, usize>, + location_indices: &'a [u32], +) -> SourceMetadata<'a, u32> { + let mut converted = SourceMetadata::new( + location_indices, + source.paulis, + source.gate_types, + source.before_flags, + ); + converted.direct_source_family_override = source.direct_source_family_override; + converted.replacement_branch = source.replacement_branch; + converted +} + /// Converts a DEM measurement-record offset to an absolute measurement index. /// /// Negative offsets count backward from the end of the measurement record @@ -1410,8 +2120,59 @@ impl std::error::Error for PecosDemMetadataError {} /// ``` #[derive(Debug, Clone)] pub struct PauliWeights { - /// (`PauliString`, weight) pairs. Weights must sum to ~1.0. + /// Post-gate (`PauliString`, weight) pairs. entries: Vec<(pecos_core::PauliString, f64)>, + /// Replacement (`PauliString`, weight) pairs. These omit the ideal gate before + /// applying the stored Pauli. + replacement_entries: Vec<(pecos_core::PauliString, f64)>, +} + +/// Approximation used for replacement two-qubit fault branches. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ReplacementBranchApproximation { + /// Ignore the omitted ideal gate and treat replacement entries like post-gate + /// Pauli entries. Useful as a baseline comparison. + IgnoreGateRemoval, + /// Convolve replacement entries with the Pauli twirl of the omitted ideal + /// gate's dagger. This is the default approximation for starred entries. + #[default] + PauliTwirlOmittedGate, + /// Evaluate Pauli-projected replacement branches as their own contribution + /// streams after convolving with the omitted gate's Pauli twirl, instead of + /// folding them into the ordinary post-gate two-qubit rate vector. + /// + /// This preserves replacement-branch provenance in contribution metadata, + /// but it is still a Pauli projection rather than exact non-Pauli branch + /// replay. + BranchImpact, + /// Request exact replay of replacement branches against the circuit. + /// + /// This mode is intentionally fail-loud until a circuit-aware replay + /// provider is attached. A replacement branch can be a non-Pauli Clifford + /// effect in the ideal-circuit frame, which standard DEM rows cannot always + /// represent as one deterministic Pauli-like event. + ExactBranchReplay, +} + +/// A single Pauli-projected impact term produced by a replacement branch. +/// +/// This is intentionally an intermediate representation rather than a final +/// DEM contribution. It preserves the expanded branch term until the builder +/// can evaluate the term against detector/observable metadata. Today the +/// projection comes from the Pauli twirl of the omitted two-qubit Clifford +/// gate. A future exact replay implementation should attach the same +/// replacement-branch contribution metadata, but may need to bypass this +/// Pauli-projected shape when the branch is not representable as a Pauli fault. +#[derive(Debug, Clone, PartialEq)] +pub struct ReplacementBranchImpact { + /// Replacement branch Pauli label before omitted-gate projection. + pub replacement_pauli_label: String, + /// Pauli-twirl term for the omitted ideal gate. + pub omitted_gate_twirl_label: String, + /// Non-identity two-qubit Pauli label (`IX` through `ZZ`) to evaluate. + pub pauli_label: String, + /// Relative probability mass for this projected branch term. + pub relative_probability: f64, } impl PauliWeights { @@ -1423,16 +2184,40 @@ impl PauliWeights { /// /// Panics if weights don't sum to ~1.0 or if any weight is negative. pub fn new(entries: impl IntoIterator) -> Self { + Self::with_replacement(entries, std::iter::empty()) + } + + /// Create from post-gate and replacement branch entries. + /// + /// Replacement entries model branches where the ideal two-qubit gate is omitted + /// and the entry Pauli is applied instead. The combined post-gate and replacement + /// branch weights must sum to ~1.0. + /// + /// # Panics + /// + /// Panics if weights don't sum to ~1.0 or if any weight is negative. + pub fn with_replacement( + entries: impl IntoIterator, + replacement_entries: impl IntoIterator, + ) -> Self { let entries: Vec<_> = entries.into_iter().collect(); - let sum: f64 = entries.iter().map(|(_, w)| w).sum(); + let replacement_entries: Vec<_> = replacement_entries.into_iter().collect(); + let sum: f64 = entries + .iter() + .chain(replacement_entries.iter()) + .map(|(_, w)| w) + .sum(); assert!( (sum - 1.0).abs() < 1e-6, "PauliWeights must sum to 1.0, got {sum}" ); - for (ps, w) in &entries { + for (ps, w) in entries.iter().chain(replacement_entries.iter()) { assert!(*w >= 0.0, "Weight for {ps} must be non-negative, got {w}"); } - Self { entries } + Self { + entries, + replacement_entries, + } } /// Uniform weights for single-qubit gates: X, Y, Z each with 1/3. @@ -1441,6 +2226,7 @@ impl PauliWeights { use pecos_core::pauli::{X, Y, Z}; Self { entries: vec![(X(0), 1.0 / 3.0), (Y(0), 1.0 / 3.0), (Z(0), 1.0 / 3.0)], + replacement_entries: Vec::new(), } } @@ -1467,6 +2253,7 @@ impl PauliWeights { (Z(0) & Y(1), w), (Z(0) & Z(1), w), ], + replacement_entries: Vec::new(), } } @@ -1484,11 +2271,124 @@ impl PauliWeights { .map_or(0.0, |(_, w)| *w) } + /// Look up the effective two-qubit Pauli weight for a specific gate. + /// + /// Plain entries contribute directly. Replacement entries first convolve with + /// the Pauli twirl of the omitted gate, so `*II` on `SZZ` contributes half + /// `II` and half `ZZ`, while `*XX` on `SZZ` contributes half `XX` and half + /// `YY`. The identity component is intentionally not returned by callers that + /// query only non-identity Pauli labels. + #[must_use] + pub fn two_qubit_weight_for( + &self, + gate_type: GateType, + pauli: &pecos_core::PauliString, + approximation: ReplacementBranchApproximation, + ) -> f64 { + let Ok(query_label) = two_qubit_pauli_label(pauli) else { + return 0.0; + }; + let direct = self.post_gate_two_qubit_weight_for(pauli); + + if approximation == ReplacementBranchApproximation::IgnoreGateRemoval { + return direct + + self + .replacement_entries + .iter() + .filter_map(|(ps, weight)| { + (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) + }) + .sum::(); + } + + direct + + self + .replacement_branch_impacts(gate_type) + .into_iter() + .filter(|impact| impact.pauli_label == query_label) + .map(|impact| impact.relative_probability) + .sum::() + } + + /// Look up only the plain post-gate two-qubit Pauli weight. + #[must_use] + pub fn post_gate_two_qubit_weight_for(&self, pauli: &pecos_core::PauliString) -> f64 { + let Ok(query_label) = two_qubit_pauli_label(pauli) else { + return 0.0; + }; + self.entries + .iter() + .filter_map(|(ps, weight)| { + (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) + }) + .sum::() + } + + /// Non-identity branch-impact terms from replacement entries. + /// + /// Each starred replacement entry is convolved with the Pauli twirl of the + /// omitted ideal gate. The returned terms are deliberately not aggregated: + /// the builder should evaluate each branch term as a separate contribution + /// before the DEM's normal contribution grouping combines equivalent + /// detector/logical effects. Identity effects are omitted because DEM + /// builders only emit branches that flip detectors or logical observables. + #[must_use] + pub fn replacement_branch_impacts(&self, gate_type: GateType) -> Vec { + let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { + return Vec::new(); + }; + let mut impacts = Vec::new(); + for (ps, replacement_weight) in &self.replacement_entries { + let Ok(replacement_label) = two_qubit_pauli_label(ps) else { + continue; + }; + for (twirl_label, twirl_weight) in &twirl { + let effective_label = + multiply_two_qubit_pauli_labels(&replacement_label, twirl_label); + if effective_label == "II" { + continue; + } + impacts.push(ReplacementBranchImpact { + replacement_pauli_label: replacement_label.clone(), + omitted_gate_twirl_label: (*twirl_label).to_string(), + pauli_label: effective_label, + relative_probability: replacement_weight * twirl_weight, + }); + } + } + impacts + } + + /// Effective non-identity branch-impact weights from replacement entries. + /// + /// This is a convenience aggregation for callers that do not need source + /// branch identity. + #[must_use] + pub fn replacement_branch_impact_weights(&self, gate_type: GateType) -> BTreeMap { + let mut weights = BTreeMap::new(); + for impact in self.replacement_branch_impacts(gate_type) { + *weights.entry(impact.pauli_label).or_insert(0.0) += impact.relative_probability; + } + weights + } + /// Get all entries as `(PauliString, weight)` pairs. #[must_use] pub fn entries(&self) -> &[(pecos_core::PauliString, f64)] { &self.entries } + + /// Get replacement entries as `(PauliString, weight)` pairs. + #[must_use] + pub fn replacement_entries(&self) -> &[(pecos_core::PauliString, f64)] { + &self.replacement_entries + } + + /// Whether this weight table contains starred replacement branches. + #[must_use] + pub fn has_replacement_entries(&self) -> bool { + !self.replacement_entries.is_empty() + } } impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights { @@ -1497,13 +2397,56 @@ impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights } } +/// Return the Pauli-twirled channel for omitting a supported two-qubit Clifford gate. +/// +/// Some physical error models have *replacement* fault branches: when the branch +/// fires, the intended gate is not applied and the branch operation is applied +/// instead. A DEM built in the ideal-circuit frame can approximate that missing +/// operation by convolving the replacement branch with the Pauli twirl of the +/// omitted gate's inverse. Clifford gates and their adjoints have the same +/// Pauli-twirl probabilities, so this helper returns the distribution in terms +/// of two-qubit Pauli labels, including `"II"` when present. +/// +/// This helper is intentionally parameter-free and device-agnostic. Callers +/// remain responsible for deciding which fault branches are replacement +/// branches, how leakage symbols are projected, and how branch probabilities are +/// scaled. +#[must_use] +pub fn omitted_two_qubit_gate_pauli_twirl( + gate_type: GateType, +) -> Option> { + let entries: &[(&str, f64)] = match gate_type { + GateType::CX => &[("II", 0.25), ("IX", 0.25), ("ZI", 0.25), ("ZX", 0.25)], + GateType::CY => &[("II", 0.25), ("IY", 0.25), ("ZI", 0.25), ("ZY", 0.25)], + GateType::CZ => &[("II", 0.25), ("IZ", 0.25), ("ZI", 0.25), ("ZZ", 0.25)], + GateType::SWAP => &[("II", 0.25), ("XX", 0.25), ("YY", 0.25), ("ZZ", 0.25)], + GateType::SXX | GateType::SXXdg => &[("II", 0.5), ("XX", 0.5)], + GateType::SYY | GateType::SYYdg => &[("II", 0.5), ("YY", 0.5)], + GateType::SZZ | GateType::SZZdg => &[("II", 0.5), ("ZZ", 0.5)], + _ => return None, + }; + Some(entries.iter().copied().collect()) +} + /// Noise model configuration for circuit-level fault analysis. #[derive(Debug, Clone)] pub struct NoiseConfig { /// Single-qubit gate error rate. pub p1: f64, + /// Optional per-gate total error-rate overrides for single-qubit gates. + /// + /// When a single-qubit gate type appears here, this total rate replaces + /// `p1` while still using `p1_weights` to distribute probability across + /// Pauli channels. + pub p1_gate_rates: BTreeMap, /// Two-qubit gate error rate. pub p2: f64, + /// Optional per-gate total error-rate overrides for two-qubit gates. + /// + /// When a two-qubit gate type appears here, this total rate replaces + /// `p2` while still using `p2_weights` to distribute probability across + /// Pauli-pair channels. + pub p2_gate_rates: BTreeMap, /// Measurement error rate. pub p_meas: f64, /// Initialization (prep) error rate. @@ -1532,6 +2475,8 @@ pub struct NoiseConfig { /// Maps each two-qubit Pauli fault to its relative probability. Must sum to ~1.0. /// Default (None) = uniform depolarizing. pub p2_weights: Option, + /// Approximation used for replacement two-qubit entries in `p2_weights`. + pub p2_replacement_approximation: ReplacementBranchApproximation, /// Coherent idle RZ rotation angle per time unit. /// /// When set (> 0), idle gates contribute a coherent Z rotation in addition @@ -1541,6 +2486,139 @@ pub struct NoiseConfig { /// /// This is the EEG H-type noise model for idle gates. Default is 0.0. pub idle_rz: f64, + /// Stochastic Z-memory error rate linear in idle duration. + /// + /// This mirrors PECOS engine idle memory noise in a DEM-compatible Pauli + /// channel: each explicit `Idle(duration, q)` contributes an independent + /// Z fault with probability `p_idle_linear_rate * duration`. + /// + /// This is the legacy Z-axis alias for `p_idle_z_linear_rate`. + pub p_idle_linear_rate: f64, + /// Stochastic Z-memory error rate for the quadratic idle term. + /// + /// Each explicit `Idle(duration, q)` contributes a Z-fault probability + /// term `p_idle_quadratic_rate * duration^2`. + /// + /// This is the legacy Z-axis alias for `p_idle_z_quadratic_rate`. + pub p_idle_quadratic_rate: f64, + /// Stochastic Z-memory sine-law rate for the quadratic idle term. + /// + /// Each explicit `Idle(duration, q)` contributes a Z-fault probability + /// term `sin(p_idle_quadratic_sine_rate * duration)^2`. This preserves + /// the small-duration quadratic behavior of coherent dephasing models + /// without changing the coefficient-style `p_idle_quadratic_rate` API. + /// + /// This is the legacy Z-axis alias for `p_idle_z_quadratic_sine_rate`. + pub p_idle_quadratic_sine_rate: f64, + /// Stochastic X-memory error rate linear in idle duration. + pub p_idle_x_linear_rate: f64, + /// Stochastic Y-memory error rate linear in idle duration. + pub p_idle_y_linear_rate: f64, + /// Stochastic X-memory error rate quadratic in idle duration. + pub p_idle_x_quadratic_rate: f64, + /// Stochastic Y-memory error rate quadratic in idle duration. + pub p_idle_y_quadratic_rate: f64, + /// Stochastic X-memory sine-law rate for the quadratic idle term. + pub p_idle_x_quadratic_sine_rate: f64, + /// Stochastic Y-memory sine-law rate for the quadratic idle term. + pub p_idle_y_quadratic_sine_rate: f64, + /// Per-payload local measurement-crosstalk event rate. + /// + /// This rate is multiplied by the selected hidden-measurement transition + /// probability from [`MeasurementCrosstalkTransitionModel`] when crosstalk + /// DEM replay is enabled. + pub p_meas_crosstalk_local: f64, + /// Per-payload global measurement-crosstalk event rate. + pub p_meas_crosstalk_global: f64, + /// Hidden-measurement transition probabilities used by measurement + /// crosstalk DEM replay. + pub p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel, + /// Policy for converting measurement-crosstalk payloads into DEM sources. + pub measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode, +} + +/// Policy for converting runtime measurement-crosstalk payloads into DEMs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MeasurementCrosstalkDemMode { + /// Ignore measurement-crosstalk payloads when constructing DEMs. + #[default] + Omitted, + /// Replay payloads exactly when the hidden measurement outcome is + /// deterministic and state-independent. + ExactDeterministic, + /// Replay deterministic local payloads while treating hidden leakage + /// transitions as the `leak2depolar` replacement channel: one quarter each + /// of I, X, Y, and Z on the victim qubit. + ExactDeterministicLeakageAsDepolarizing, + /// Replay payloads by averaging over the hidden measurement outcome while + /// treating leakage transitions as the `leak2depolar` replacement channel. + /// + /// This is appropriate when the hidden measurement is state-dependent + /// rather than deterministic, as happens for global measurement crosstalk + /// victims in generic circuits. + AveragedHiddenLeakageAsDepolarizing, +} + +/// Hidden-measurement transition probabilities for local measurement crosstalk. +/// +/// The no-op probabilities are implicit: +/// `p(0->0) = 1 - p_0_to_1 - p_0_to_leak` and +/// `p(1->1) = 1 - p_1_to_0 - p_1_to_leak`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeasurementCrosstalkTransitionModel { + /// Probability that a hidden 0 result flips to 1. + pub p_0_to_1: f64, + /// Probability that a hidden 0 result leaks. + pub p_0_to_leak: f64, + /// Probability that a hidden 1 result flips to 0. + pub p_1_to_0: f64, + /// Probability that a hidden 1 result leaks. + pub p_1_to_leak: f64, +} + +impl MeasurementCrosstalkTransitionModel { + /// Creates a no-leakage measurement-crosstalk transition model. + #[must_use] + pub const fn bit_flip(p_0_to_1: f64, p_1_to_0: f64) -> Self { + Self { + p_0_to_1, + p_0_to_leak: 0.0, + p_1_to_0, + p_1_to_leak: 0.0, + } + } + + /// Returns true if all transition probabilities are finite and valid. + #[must_use] + pub fn is_valid(&self) -> bool { + self.p_0_to_1.is_finite() + && self.p_0_to_leak.is_finite() + && self.p_1_to_0.is_finite() + && self.p_1_to_leak.is_finite() + && self.p_0_to_1 >= 0.0 + && self.p_0_to_leak >= 0.0 + && self.p_1_to_0 >= 0.0 + && self.p_1_to_leak >= 0.0 + && self.p_0_to_1 + self.p_0_to_leak <= 1.0 + f64::EPSILON + && self.p_1_to_0 + self.p_1_to_leak <= 1.0 + f64::EPSILON + } + + /// Returns true when any leakage transition probability is non-zero. + #[must_use] + pub fn has_leakage(&self) -> bool { + self.p_0_to_leak > 0.0 || self.p_1_to_leak > 0.0 + } +} + +impl Default for MeasurementCrosstalkTransitionModel { + fn default() -> Self { + Self { + p_0_to_1: 0.0, + p_0_to_leak: 0.0, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + } + } } /// Per-Pauli error probabilities for a single qubit. @@ -1597,7 +2675,9 @@ impl Default for NoiseConfig { fn default() -> Self { Self { p1: 0.01, + p1_gate_rates: BTreeMap::new(), p2: 0.01, + p2_gate_rates: BTreeMap::new(), p_meas: 0.01, p_prep: 0.01, p_idle: 0.0, @@ -1605,7 +2685,21 @@ impl Default for NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, + p_idle_quadratic_sine_rate: 0.0, + p_idle_x_linear_rate: 0.0, + p_idle_y_linear_rate: 0.0, + p_idle_x_quadratic_rate: 0.0, + p_idle_y_quadratic_rate: 0.0, + p_idle_x_quadratic_sine_rate: 0.0, + p_idle_y_quadratic_sine_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } } @@ -1616,7 +2710,9 @@ impl NoiseConfig { pub fn new(p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { Self { p1, + p1_gate_rates: BTreeMap::new(), p2, + p2_gate_rates: BTreeMap::new(), p_meas, p_prep, p_idle: 0.0, @@ -1624,7 +2720,21 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, + p_idle_quadratic_sine_rate: 0.0, + p_idle_x_linear_rate: 0.0, + p_idle_y_linear_rate: 0.0, + p_idle_x_quadratic_rate: 0.0, + p_idle_y_quadratic_rate: 0.0, + p_idle_x_quadratic_sine_rate: 0.0, + p_idle_y_quadratic_sine_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -1633,7 +2743,9 @@ impl NoiseConfig { pub fn with_idle(p1: f64, p2: f64, p_meas: f64, p_prep: f64, p_idle: f64) -> Self { Self { p1, + p1_gate_rates: BTreeMap::new(), p2, + p2_gate_rates: BTreeMap::new(), p_meas, p_prep, p_idle, @@ -1641,7 +2753,21 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, + p_idle_quadratic_sine_rate: 0.0, + p_idle_x_linear_rate: 0.0, + p_idle_y_linear_rate: 0.0, + p_idle_x_quadratic_rate: 0.0, + p_idle_y_quadratic_rate: 0.0, + p_idle_x_quadratic_sine_rate: 0.0, + p_idle_y_quadratic_sine_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -1650,7 +2776,9 @@ impl NoiseConfig { pub fn uniform(p: f64) -> Self { Self { p1: p, + p1_gate_rates: BTreeMap::new(), p2: p, + p2_gate_rates: BTreeMap::new(), p_meas: p, p_prep: p, p_idle: p, @@ -1658,7 +2786,21 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, + p_idle_quadratic_sine_rate: 0.0, + p_idle_x_linear_rate: 0.0, + p_idle_y_linear_rate: 0.0, + p_idle_x_quadratic_rate: 0.0, + p_idle_y_quadratic_rate: 0.0, + p_idle_x_quadratic_sine_rate: 0.0, + p_idle_y_quadratic_sine_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -1669,6 +2811,64 @@ impl NoiseConfig { self } + /// Sets the linear stochastic Z-memory rate for explicit idle gates. + #[must_use] + pub fn set_idle_linear_rate(mut self, rate: f64) -> Self { + self.p_idle_linear_rate = rate.max(0.0); + self + } + + /// Sets the quadratic stochastic Z-memory rate for explicit idle gates. + #[must_use] + pub fn set_idle_quadratic_rate(mut self, rate: f64) -> Self { + self.p_idle_quadratic_rate = rate.max(0.0); + self + } + + /// Sets the sine-law quadratic stochastic Z-memory rate for explicit idle gates. + #[must_use] + pub fn set_idle_quadratic_sine_rate(mut self, rate: f64) -> Self { + self.p_idle_quadratic_sine_rate = rate.max(0.0); + self + } + + /// Sets the linear stochastic Pauli-memory rates for explicit idle gates. + #[must_use] + pub fn set_idle_pauli_linear_rates(mut self, px_rate: f64, py_rate: f64, pz_rate: f64) -> Self { + self.p_idle_x_linear_rate = px_rate.max(0.0); + self.p_idle_y_linear_rate = py_rate.max(0.0); + self.p_idle_linear_rate = pz_rate.max(0.0); + self + } + + /// Sets the quadratic stochastic Pauli-memory rates for explicit idle gates. + #[must_use] + pub fn set_idle_pauli_quadratic_rates( + mut self, + px_rate: f64, + py_rate: f64, + pz_rate: f64, + ) -> Self { + self.p_idle_x_quadratic_rate = px_rate.max(0.0); + self.p_idle_y_quadratic_rate = py_rate.max(0.0); + self.p_idle_quadratic_rate = pz_rate.max(0.0); + self + } + + /// Sets the sine-law quadratic stochastic Pauli-memory rates for explicit idle gates. + #[must_use] + pub fn set_idle_pauli_quadratic_sine_rates( + mut self, + px_rate: f64, + py_rate: f64, + pz_rate: f64, + ) -> Self { + self.p_idle_x_quadratic_sine_rate = px_rate.max(0.0); + self.p_idle_y_quadratic_sine_rate = py_rate.max(0.0); + self.p_idle_quadratic_sine_rate = pz_rate.max(0.0); + self + } + /// Sets T1/T2 relaxation times for idle noise. /// /// When set, idle gates use the Pauli-twirled T1/T2 model instead of @@ -1711,6 +2911,26 @@ impl NoiseConfig { self } + /// Sets a total single-qubit error-rate override for one gate type. + /// + /// The override changes only the total rate. If `p1_weights` is configured, + /// those weights still determine the relative Pauli distribution for this + /// gate. + #[must_use] + pub fn set_p1_gate_rate(mut self, gate_type: GateType, rate: f64) -> Self { + self.p1_gate_rates.insert(gate_type, rate.max(0.0)); + self + } + + /// Returns the total single-qubit error rate for `gate_type`. + #[must_use] + pub fn p1_rate_for_gate(&self, gate_type: GateType) -> f64 { + self.p1_gate_rates + .get(&gate_type) + .copied() + .unwrap_or(self.p1) + } + /// Sets custom per-Pauli weights for two-qubit gates. #[must_use] pub fn set_p2_weights(mut self, weights: PauliWeights) -> Self { @@ -1718,6 +2938,73 @@ impl NoiseConfig { self } + /// Sets a total two-qubit error-rate override for one gate type. + /// + /// The override changes only the total rate. If `p2_weights` is configured, + /// those weights still determine the relative Pauli-pair distribution for + /// this gate. + #[must_use] + pub fn set_p2_gate_rate(mut self, gate_type: GateType, rate: f64) -> Self { + self.p2_gate_rates.insert(gate_type, rate.max(0.0)); + self + } + + /// Returns the total two-qubit error rate for `gate_type`. + #[must_use] + pub fn p2_rate_for_gate(&self, gate_type: GateType) -> f64 { + self.p2_gate_rates + .get(&gate_type) + .copied() + .unwrap_or(self.p2) + } + + /// Returns true when any scalar or per-gate two-qubit rate is positive. + #[must_use] + pub fn has_any_p2_noise(&self) -> bool { + self.p2 > 0.0 || self.p2_gate_rates.values().any(|rate| *rate > 0.0) + } + + /// Sets how replacement entries in `p2_weights` are approximated. + #[must_use] + pub fn set_p2_replacement_approximation( + mut self, + approximation: ReplacementBranchApproximation, + ) -> Self { + self.p2_replacement_approximation = approximation; + self + } + + /// Sets the local measurement-crosstalk payload event rate. + #[must_use] + pub fn set_measurement_crosstalk_local_rate(mut self, rate: f64) -> Self { + self.p_meas_crosstalk_local = rate.max(0.0); + self + } + + /// Sets the global measurement-crosstalk payload event rate. + #[must_use] + pub fn set_measurement_crosstalk_global_rate(mut self, rate: f64) -> Self { + self.p_meas_crosstalk_global = rate.max(0.0); + self + } + + /// Sets the hidden-measurement transition model for crosstalk DEM replay. + #[must_use] + pub fn set_measurement_crosstalk_transition_model( + mut self, + model: MeasurementCrosstalkTransitionModel, + ) -> Self { + self.p_meas_crosstalk_model = model; + self + } + + /// Sets the policy for converting measurement-crosstalk payloads into DEMs. + #[must_use] + pub fn set_measurement_crosstalk_dem_mode(mut self, mode: MeasurementCrosstalkDemMode) -> Self { + self.measurement_crosstalk_dem_mode = mode; + self + } + /// Sets idle noise from a coherent RZ rotation angle per time unit. /// /// Converts `idle_rz` (the angle theta of an RZ(theta) rotation applied @@ -1747,17 +3034,87 @@ impl NoiseConfig { self } + fn idle_memory_probability( + linear_rate: f64, + quadratic_rate: f64, + quadratic_sine_rate: f64, + duration: f64, + ) -> f64 { + let duration = duration.max(0.0); + let sine_angle = quadratic_sine_rate.max(0.0) * duration; + (linear_rate.max(0.0) * duration + + quadratic_rate.max(0.0) * duration * duration + + sine_angle.sin().powi(2)) + .clamp(0.0, 1.0) + } + + /// Dedicated idle-memory Pauli probabilities for `Idle(duration, q)`. + #[must_use] + pub fn idle_memory_pauli_probs(&self, duration: f64) -> PauliProbs { + let mut probs = PauliProbs { + px: Self::idle_memory_probability( + self.p_idle_x_linear_rate, + self.p_idle_x_quadratic_rate, + self.p_idle_x_quadratic_sine_rate, + duration, + ), + py: Self::idle_memory_probability( + self.p_idle_y_linear_rate, + self.p_idle_y_quadratic_rate, + self.p_idle_y_quadratic_sine_rate, + duration, + ), + pz: Self::idle_memory_probability( + self.p_idle_linear_rate, + self.p_idle_quadratic_rate, + self.p_idle_quadratic_sine_rate, + duration, + ), + }; + let total = probs.total(); + if total > 1.0 { + probs.px /= total; + probs.py /= total; + probs.pz /= total; + } + probs + } + + fn compose_pauli_channel(probs: PauliProbs, channel: PauliProbs) -> PauliProbs { + if channel.total() <= f64::EPSILON { + return probs; + } + + let p_identity = (1.0 - probs.total()).max(0.0); + let c_identity = (1.0 - channel.total()).max(0.0); + PauliProbs { + px: p_identity * channel.px + + probs.px * c_identity + + probs.py * channel.pz + + probs.pz * channel.py, + py: p_identity * channel.py + + probs.py * c_identity + + probs.px * channel.pz + + probs.pz * channel.px, + pz: p_identity * channel.pz + + probs.pz * c_identity + + probs.px * channel.py + + probs.py * channel.px, + } + } + /// Compute per-Pauli idle noise probabilities for a given duration. /// /// If T1/T2 are set, uses the Pauli-twirled model (biased noise). /// Otherwise, uses uniform depolarizing with `p_idle * duration`. #[must_use] pub fn idle_pauli_probs(&self, duration: f64) -> PauliProbs { - if let (Some(t1), Some(t2)) = (self.t1, self.t2) { + let probs = if let (Some(t1), Some(t2)) = (self.t1, self.t2) { PauliProbs::from_t1_t2(duration, t1, t2) } else { PauliProbs::depolarizing((self.p_idle * duration).min(1.0)) - } + }; + Self::compose_pauli_channel(probs, self.idle_memory_pauli_probs(duration)) } /// Returns true when idle locations use the dedicated idle-noise model. @@ -1765,7 +3122,17 @@ impl NoiseConfig { /// Otherwise `Idle` is a no-op for noise. #[must_use] pub fn uses_dedicated_idle_noise(&self) -> bool { - self.p_idle > 0.0 || matches!((self.t1, self.t2), (Some(_), Some(_))) + self.p_idle > 0.0 + || matches!((self.t1, self.t2), (Some(_), Some(_))) + || self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate.abs() > f64::EPSILON + || self.p_idle_quadratic_sine_rate > 0.0 + || self.p_idle_x_linear_rate > 0.0 + || self.p_idle_y_linear_rate > 0.0 + || self.p_idle_x_quadratic_rate > 0.0 + || self.p_idle_y_quadratic_rate > 0.0 + || self.p_idle_x_quadratic_sine_rate > 0.0 + || self.p_idle_y_quadratic_sine_rate > 0.0 } } @@ -1777,6 +3144,43 @@ fn pauli_pattern(ps: &pecos_core::PauliString) -> Vec { ps.paulis().iter().map(|&(p, _)| p).collect() } +fn two_qubit_pauli_label(ps: &pecos_core::PauliString) -> Result { + let mut chars = ['I', 'I']; + for &(pauli, qubit) in ps.paulis() { + let idx = qubit.index(); + if idx >= 2 { + return Err(format!( + "two-qubit Pauli weights only support qubit indices 0 and 1, got {idx}" + )); + } + chars[idx] = match pauli { + pecos_core::Pauli::I => 'I', + pecos_core::Pauli::X => 'X', + pecos_core::Pauli::Y => 'Y', + pecos_core::Pauli::Z => 'Z', + }; + } + Ok(chars.iter().collect()) +} + +fn multiply_two_qubit_pauli_labels(left: &str, right: &str) -> String { + left.chars() + .zip(right.chars()) + .map(|(a, b)| multiply_pauli_labels(a, b)) + .collect() +} + +fn multiply_pauli_labels(left: char, right: char) -> char { + match (left, right) { + ('I', p) | (p, 'I') => p, + ('X', 'X') | ('Y', 'Y') | ('Z', 'Z') => 'I', + ('X', 'Y') | ('Y', 'X') => 'Z', + ('X', 'Z') | ('Z', 'X') => 'Y', + ('Y', 'Z') | ('Z', 'Y') => 'X', + _ => unreachable!("validated Pauli labels contain only I/X/Y/Z"), + } +} + fn pecos_metadata_dem_output_value(target: &DemOutput) -> serde_json::Value { serde_json::json!({ "id": target.id, @@ -2172,8 +3576,9 @@ impl PerGateTypeNoise { self } - /// Lookup 1Q Pauli rate for a gate. Returns `base.p1 / 3.0` if the - /// gate type is not in the map. `pauli_idx` is 0=X, 1=Y, 2=Z. + /// Lookup 1Q Pauli rate for a gate. Returns the base single-qubit gate + /// rate divided over the 3 Pauli channels if the gate type is not in the + /// map. `pauli_idx` is 0=X, 1=Y, 2=Z. /// /// `Idle` is a no-op by default. It receives noise only from explicitly /// attached idle rates or from the base idle-noise model. @@ -2194,11 +3599,12 @@ impl PerGateTypeNoise { } return 0.0; } - self.base.p1 / 3.0 + self.base.p1_rate_for_gate(gate) / 3.0 } /// Lookup 1Q Pauli rate for a gate on a specific qubit. Tries the - /// per-qubit map first, then the per-gate-type map, then `base.p1 / 3.0`. + /// per-qubit map first, then the per-gate-type map, then the base + /// single-qubit gate rate divided over the 3 Pauli channels. /// `pauli_idx` is 0=X, 1=Y, 2=Z. #[must_use] pub fn rate_1q_on(&self, gate: GateType, qubit: QubitId, pauli_idx: usize) -> f64 { @@ -2208,18 +3614,20 @@ impl PerGateTypeNoise { self.rate_1q(gate, pauli_idx) } - /// Lookup 2Q Pauli pair rate for a gate. Returns `base.p2 / 15.0` - /// if the gate type is not in the map. `pair_idx` follows [`PAULI_2Q_ORDER`]. + /// Lookup 2Q Pauli pair rate for a gate. Returns the base two-qubit gate + /// rate divided over the 15 Pauli pairs if the gate type is not in the + /// map. `pair_idx` follows [`PAULI_2Q_ORDER`]. #[must_use] pub fn rate_2q(&self, gate: GateType, pair_idx: usize) -> f64 { self.rates_2q .get(&gate) - .map_or(self.base.p2 / 15.0, |r| r[pair_idx]) + .map_or(self.base.p2_rate_for_gate(gate) / 15.0, |r| r[pair_idx]) } /// Lookup 2Q Pauli pair rate for a gate on a specific ordered /// qubit pair. Tries `(gate, q_control, q_target)` in the per-qubits - /// map first, then the per-gate-type map, then `base.p2 / 15.0`. + /// map first, then the per-gate-type map, then the base two-qubit gate + /// rate divided over the 15 Pauli pairs. #[must_use] pub fn rate_2q_on( &self, @@ -2235,27 +3643,130 @@ impl PerGateTypeNoise { } } -// ============================================================================ -// Measurement Noise Model (MNM) -// ============================================================================ +/// Permutation from [`PAULI_2Q_ORDER`] indices to pecos-neo's +/// `TWO_QUBIT_PAULIS` ordering, derived from the two canonical constants +/// so it cannot silently drift. +#[cfg(feature = "neo")] +fn qec_to_neo_2q_rates(qec_rates: &[f64; 15]) -> [f64; 15] { + fn pauli_label(gate: pecos_neo::command::GateType) -> &'static str { + match gate { + pecos_neo::command::GateType::I => "I", + pecos_neo::command::GateType::X => "X", + pecos_neo::command::GateType::Y => "Y", + pecos_neo::command::GateType::Z => "Z", + other => unreachable!("TWO_QUBIT_PAULIS contains only Paulis, got {other:?}"), + } + } -/// 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]>, + let mut neo_rates = [0.0; 15]; + for (neo_idx, &(first, second)) in pecos_neo::noise::TWO_QUBIT_PAULIS.iter().enumerate() { + let label = format!("{}{}", pauli_label(first), pauli_label(second)); + let qec_idx = PAULI_2Q_ORDER + .iter() + .position(|&entry| entry == label) + .expect("every non-identity Pauli pair appears in PAULI_2Q_ORDER"); + neo_rates[neo_idx] = qec_rates[qec_idx]; + } + neo_rates } -impl MeasurementMechanism { - /// Creates a new empty measurement mechanism. +#[cfg(feature = "neo")] +impl PerGateTypeNoise { + /// Convert to a pecos-neo [`PerGatePauliChannel`] so circuit-level + /// Monte Carlo can run the same noise that drives DEM generation. + /// + /// Gate types translate via the canonical `From` impl between the two + /// `GateType` enums; two-qubit rate arrays are permuted from + /// [`PAULI_2Q_ORDER`] into neo's `TWO_QUBIT_PAULIS` ordering. + /// + /// The neo stack models idle noise through its `IdleChannel`/time + /// events, not gate events, so idle noise cannot be carried by this + /// conversion. Rather than silently dropping noise that the DEM built + /// from the same configuration WOULD include, any idle configuration + /// is rejected: compose a neo `IdleChannel` explicitly and zero the + /// idle settings here. + /// + /// # Panics + /// + /// Panics if the configuration carries idle noise in any form: + /// `Idle` entries in the per-gate or per-qubit rate maps, or a base + /// `NoiseConfig` with nonzero `p_idle`/`idle_rz` or `t1`/`t2` set. + /// + /// [`PerGatePauliChannel`]: pecos_neo::noise::PerGatePauliChannel #[must_use] - pub fn new() -> Self { - Self::default() - } + pub fn to_neo_channel(&self) -> pecos_neo::noise::PerGatePauliChannel { + use pecos_neo::command::GateType as NeoGateType; + + let has_idle_entries = self.rates_1q.contains_key(&GateType::Idle) + || self + .rates_1q_per_qubit + .keys() + .any(|&(gate, _)| gate == GateType::Idle); + assert!( + !has_idle_entries + && self.base.p_idle == 0.0 + && self.base.idle_rz == 0.0 + && self.base.t1.is_none() + && self.base.t2.is_none(), + "PerGateTypeNoise::to_neo_channel cannot carry idle noise (the neo stack models \ + idle through IdleChannel/time events, not gate events); the DEM built from this \ + configuration would include idle contributions, so converting would silently \ + change the physics. Zero p_idle/idle_rz/t1/t2 and remove Idle rate entries, then \ + compose a neo IdleChannel explicitly if idle noise is needed." + ); + + let mut channel = pecos_neo::noise::PerGatePauliChannel::new() + .with_base(self.base.p1, self.base.p2) + .with_meas_init(self.p_meas, self.p_init); + + for (&gate, &rates) in &self.rates_1q { + channel = channel.with_1q_rates(NeoGateType::from(gate), rates); + } + for (&gate, &rates) in &self.rates_2q { + channel = channel.with_2q_rates(NeoGateType::from(gate), qec_to_neo_2q_rates(&rates)); + } + for (&(gate, qubit), &rates) in &self.rates_1q_per_qubit { + channel = channel.with_1q_rates_for_qubit(NeoGateType::from(gate), qubit, rates); + } + for (&(gate, q_control, q_target), &rates) in &self.rates_2q_per_qubits { + channel = channel.with_2q_rates_for_qubits( + NeoGateType::from(gate), + q_control, + q_target, + qec_to_neo_2q_rates(&rates), + ); + } + for (&qubit, &p) in &self.measurement_rates { + channel = channel.with_meas_rate_for_qubit(qubit, p); + } + for (&qubit, &p) in &self.init_rates { + channel = channel.with_init_rate_for_qubit(qubit, p); + } + channel + } +} + +// ============================================================================ +// Measurement Noise Model (MNM) +// ============================================================================ + +/// 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] @@ -2567,6 +4078,10 @@ pub type MechanismTuple = (f64, Vec, Vec); /// Detector-coordinate tuple: `(detector_id, coordinates)`. pub type DetectorCoordinateTuple = (u32, Vec); +/// A coordinate-distance matching solution: +/// `(total_cost, paired_terminals, unpaired_singletons)`. +type CoordinateMatchSolution = (f64, Vec<(u32, u32)>, Vec); + impl DetectorErrorModel { /// Creates a new empty DEM. #[must_use] @@ -3111,14 +4626,24 @@ impl DetectorErrorModel { DirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", DirectSourceFamily::TwoLocationComponent => "TwoLocationComponent", DirectSourceFamily::TwoLocationOneSidedComponent => "TwoLocationOneSidedComponent", + DirectSourceFamily::TwoLocationReplacementBranchImpact => { + "TwoLocationReplacementBranchImpact" + } + DirectSourceFamily::TwoLocationExactReplacementBranch => { + "TwoLocationExactReplacementBranch" + } + DirectSourceFamily::MeasurementCrosstalk => "MeasurementCrosstalk", DirectSourceFamily::Other => "Other", } } - let graphlike_set = self.collect_graphlike_mechanisms(); + let graphlike_set = BTreeSet::new(); let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); let mut by_render: BTreeMap<(FaultMechanism, String), Accumulator> = BTreeMap::new(); for contrib in &self.contributions { @@ -3130,7 +4655,10 @@ impl DetectorErrorModel { contrib, &graphlike_index, None, + None, two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); let acc = by_render @@ -3197,10 +4725,59 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> Vec { - let graphlike_set = self.collect_graphlike_mechanisms(); + self.contribution_render_records_inner( + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) + } + + /// Returns per-contribution render records for the source-informed + /// graphlike renderer. + #[must_use] + pub fn contribution_source_graphlike_render_records(&self) -> Vec { + self.contribution_render_records_inner( + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents, + ) + } + + fn contribution_render_records_inner( + &self, + two_detector_direct_policy: TwoDetectorDirectRenderPolicy, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + ) -> Vec { + let graphlike_set = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + self.collect_graphlike_mechanisms() + } else { + BTreeSet::new() + }; let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); + let source_graphlike_closure = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + self.collect_source_graphlike_closure() + } else { + SourceGraphlikeClosure::default() + }; + let source_graphlike_index = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + Some(GraphlikeDecompositionIndex::from_source_closure( + &source_graphlike_closure, + )) + } else { + None + }; + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); let mut records = Vec::new(); for contrib in &self.contributions { @@ -3212,8 +4789,11 @@ impl DetectorErrorModel { Self::contribution_render_details( contrib, &graphlike_index, + source_graphlike_index.as_ref(), None, two_detector_direct_policy, + hyperedge_policy, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); records.push(ContributionRenderRecord { @@ -3256,12 +4836,7 @@ impl DetectorErrorModel { .push(FaultContribution::direct_with_source( effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), )); } @@ -3282,12 +4857,7 @@ impl DetectorErrorModel { .push(FaultContribution::direct_with_source_components( effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), components, )); } @@ -3367,12 +4937,7 @@ impl DetectorErrorModel { x_effect, z_effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), )); } @@ -3563,6 +5128,271 @@ impl DetectorErrorModel { lines.join("\n") } + fn detector_coordinate_map(&self) -> BTreeMap { + self.detectors + .iter() + .filter_map(|detector| detector.coords.map(|coords| (detector.id, coords))) + .collect() + } + + fn detector_coordinate_distance( + left: u32, + right: u32, + detector_coords: &BTreeMap, + ) -> f64 { + let left_coords = + detector_coords + .get(&left) + .copied() + .unwrap_or([f64::from(left), 0.0, 0.0]); + let right_coords = + detector_coords + .get(&right) + .copied() + .unwrap_or([f64::from(right), 0.0, 0.0]); + left_coords + .iter() + .zip(right_coords) + .map(|(a, b)| (*a - b).powi(2)) + .sum::() + .sqrt() + } + + fn min_coordinate_terminal_pairs( + detectors: &[u32], + detector_coords: &BTreeMap, + ) -> (Vec<(u32, u32)>, Vec) { + fn solve( + mask: u64, + detectors: &[u32], + detector_coords: &BTreeMap, + memo: &mut BTreeMap, + ) -> CoordinateMatchSolution { + if let Some(cached) = memo.get(&mask) { + return cached.clone(); + } + + let count = mask.count_ones(); + let result = if count == 0 { + (0.0, Vec::new(), Vec::new()) + } else if count == 1 { + let index = mask.trailing_zeros() as usize; + (0.0, Vec::new(), vec![detectors[index]]) + } else if count % 2 == 1 { + let mut best: Option = None; + for index in 0..detectors.len() { + if mask & (1_u64 << index) == 0 { + continue; + } + let rest = mask & !(1_u64 << index); + let (cost, pairs, mut singles) = solve(rest, detectors, detector_coords, memo); + singles.push(detectors[index]); + if best + .as_ref() + .is_none_or(|(best_cost, _, _)| cost < *best_cost) + { + best = Some((cost, pairs, singles)); + } + } + best.expect("odd non-empty mask must have a singleton candidate") + } else { + let first = mask.trailing_zeros() as usize; + let rest_without_first = mask & !(1_u64 << first); + let mut best: Option = None; + for second in first + 1..detectors.len() { + if rest_without_first & (1_u64 << second) == 0 { + continue; + } + let rest = rest_without_first & !(1_u64 << second); + let (sub_cost, mut pairs, singles) = + solve(rest, detectors, detector_coords, memo); + let pair = (detectors[first], detectors[second]); + let cost = sub_cost + + DetectorErrorModel::detector_coordinate_distance( + pair.0, + pair.1, + detector_coords, + ); + pairs.insert(0, pair); + if best + .as_ref() + .is_none_or(|(best_cost, _, _)| cost < *best_cost) + { + best = Some((cost, pairs, singles)); + } + } + best.expect("even mask with at least two bits must have a pair candidate") + }; + + memo.insert(mask, result.clone()); + result + } + + if detectors.len() > 20 { + return Self::greedy_coordinate_terminal_pairs(detectors, detector_coords); + } + + let mut memo = BTreeMap::new(); + let mask = (1_u64 << detectors.len()) - 1; + let (_, pairs, singles) = solve(mask, detectors, detector_coords, &mut memo); + (pairs, singles) + } + + fn greedy_coordinate_terminal_pairs( + detectors: &[u32], + detector_coords: &BTreeMap, + ) -> (Vec<(u32, u32)>, Vec) { + let mut remaining: BTreeSet = detectors.iter().copied().collect(); + let mut pairs = Vec::new(); + let mut singles = Vec::new(); + + if remaining.len() % 2 == 1 { + let singleton = remaining + .iter() + .copied() + .max_by(|left, right| { + let left_nearest = remaining + .iter() + .copied() + .filter(|candidate| candidate != left) + .map(|candidate| { + Self::detector_coordinate_distance(*left, candidate, detector_coords) + }) + .fold(f64::INFINITY, f64::min); + let right_nearest = remaining + .iter() + .copied() + .filter(|candidate| candidate != right) + .map(|candidate| { + Self::detector_coordinate_distance(*right, candidate, detector_coords) + }) + .fold(f64::INFINITY, f64::min); + left_nearest + .partial_cmp(&right_nearest) + .unwrap_or(Ordering::Equal) + }) + .expect("odd non-empty detector set should have a singleton"); + remaining.remove(&singleton); + singles.push(singleton); + } + + while let Some(left) = remaining.pop_first() { + let Some(right) = remaining.iter().copied().min_by(|a, b| { + let da = Self::detector_coordinate_distance(left, *a, detector_coords); + let db = Self::detector_coordinate_distance(left, *b, detector_coords); + da.partial_cmp(&db) + .unwrap_or(Ordering::Equal) + .then_with(|| a.cmp(b)) + }) else { + singles.push(left); + break; + }; + remaining.remove(&right); + pairs.push((left, right)); + } + + (pairs, singles) + } + + fn terminal_graphlike_parts( + effect: &FaultMechanism, + detector_coords: &BTreeMap, + ) -> Vec { + let (pairs, singles) = + Self::min_coordinate_terminal_pairs(&effect.detectors, detector_coords); + let mut parts: Vec = pairs + .into_iter() + .map(|(left, right)| FaultMechanism::from_unsorted([left, right], [])) + .collect(); + parts.extend( + singles + .into_iter() + .map(|detector| FaultMechanism::from_unsorted([detector], [])), + ); + + if parts.is_empty() { + if !effect.dem_outputs.is_empty() { + parts.push(FaultMechanism::from_unsorted( + std::iter::empty(), + effect.dem_outputs.iter().copied(), + )); + } + } else if !effect.dem_outputs.is_empty() { + let last = parts + .last_mut() + .expect("non-empty parts checked before attaching observables"); + last.dem_outputs.clone_from(&effect.dem_outputs); + } + + parts + } + + /// Converts the DEM to a terminal-only graphlike projection. + /// + /// Contributions are first grouped into the same raw mechanisms as + /// [`Self::to_string`]. Each grouped effect is then rendered as graphlike + /// components whose XOR is exactly the original detector/observable effect. + /// Pair components use only detectors present in the raw effect. Ordinary + /// low-weight effects use the exact minimum-total-distance pairing from + /// detector coordinates; unusually large effects use a deterministic + /// nearest-neighbor fallback to avoid exponential render time. This is a + /// decoder-facing projection for graph matchers; it is not source proof. + #[must_use] + pub fn to_string_terminal_graphlike_decomposed(&self) -> String { + let mut lines = Vec::new(); + + for det in &self.detectors { + if let Some([x, y, z]) = det.coords { + lines.push(format!("detector({x}, {y}, {z}) D{}", det.id)); + } else { + lines.push(format!("detector D{}", det.id)); + } + } + + for obs in &self.observables { + lines.push(format!("logical_observable L{}", obs.id)); + } + + let mut by_effect: BTreeMap = BTreeMap::new(); + for contrib in &self.contributions { + by_effect + .entry(contrib.effect.standard_effect()) + .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) + .or_insert(contrib.probability); + } + + let detector_coords = self.detector_coordinate_map(); + let mut by_targets: BTreeMap = BTreeMap::new(); + for (effect, total_prob) in by_effect { + if effect.is_standard_empty() || total_prob <= 0.0 { + continue; + } + + let targets = Self::format_decomposed_parts(Self::terminal_graphlike_parts( + &effect, + &detector_coords, + )); + if !targets.is_empty() { + by_targets + .entry(targets) + .and_modify(|p| *p = combine_independent_probs(*p, total_prob)) + .or_insert(total_prob); + } + } + + for (targets, total_prob) in by_targets { + if !targets.is_empty() && total_prob > 0.0 { + lines.push(format!( + "error({}) {}", + format_probability(total_prob), + targets + )); + } + } + + lines.join("\n") + } + fn collect_singleton_index(&self) -> SingletonDecompositionIndex { SingletonDecompositionIndex::from_contributions(&self.contributions) } @@ -3586,7 +5416,7 @@ impl DetectorErrorModel { let mut out = Vec::new(); for part in parts { - if part.is_graphlike() { + if is_detectable_graphlike_component(&part) { out.extend(Self::maximally_decompose_graphlike_effect( &part, singleton_set, @@ -3598,101 +5428,276 @@ impl DetectorErrorModel { out } - fn recorded_component_targets( - contrib: &FaultContribution, + fn source_graphlike_decompose_full_effect_or_raw( + effect: &FaultMechanism, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, singleton_set: Option<&SingletonDecompositionIndex>, - ) -> Option { - let (first, second) = contrib.direct_component_effects()?; - let targets = Self::maybe_maximally_decompose_parts( - [first, second] + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + if is_detectable_graphlike_component(effect) { + return Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set); + } + + if let Some(index) = source_graphlike_index + && let Some(parts) = index.find_hyperedge_decomposition_with_remnants_for_origin_cached( + effect, + Some(effect), + source_graphlike_path_cache, + ) + { + let parts = Self::maybe_maximally_decompose_parts(parts, singleton_set); + if parts_are_detectable_graphlike(&parts) { + return parts; + } + } + + vec![effect.clone()] + } + + fn source_component_parts(contrib: &FaultContribution) -> Vec { + if let Some((x_effect, z_effect)) = contrib.decomposition_components() { + return [x_effect, z_effect] .into_iter() + .map(|part| part.standard_effect()) .filter(|part| !part.is_empty()) - .collect(), - singleton_set, - ) - .iter() - .map(format_mechanism_targets) - .filter(|targets| !targets.is_empty()) - .collect::>() - .join(" ^ "); - if targets.is_empty() { - None + .collect(); + } + + if let Some(parts) = contrib.source_component_effects() { + return parts + .into_iter() + .map(|part| part.standard_effect()) + .filter(|part| !part.is_empty()) + .collect(); + } + + let effect = contrib.effect.standard_effect(); + if effect.is_empty() || contrib.location_indices.is_empty() { + Vec::new() } else { - Some(targets) + vec![effect] } } - fn two_detector_direct_targets( - effect: &FaultMechanism, - singleton_set: Option<&SingletonDecompositionIndex>, - ) -> String { - Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ ") + fn collect_source_graphlike_mechanisms(&self) -> BTreeSet { + let mut graphlike = BTreeSet::new(); + for contrib in &self.contributions { + let effect = contrib.effect.standard_effect(); + if !contrib.location_indices.is_empty() && is_detectable_graphlike_component(&effect) { + graphlike.insert(effect); + } + for part in Self::source_component_parts(contrib) { + if is_detectable_graphlike_component(&part) { + graphlike.insert(part); + } + } + } + graphlike } - fn contribution_render_details( - contrib: &FaultContribution, - graphlike_index: &GraphlikeDecompositionIndex, - singleton_set: Option<&SingletonDecompositionIndex>, - two_detector_direct_policy: TwoDetectorDirectRenderPolicy, - cache: &mut BTreeMap<(FaultMechanism, FaultSourceType), String>, - ) -> (String, ContributionRenderStrategy, Option) { - let recorded_component_targets = Self::recorded_component_targets(contrib, singleton_set); - let key = (contrib.effect.clone(), contrib.source_type.clone()); - if let Some(cached) = cache.get(&key) { - let strategy = if contrib.decomposition_components().is_some() { - ContributionRenderStrategy::SourceComponents - } else if contrib.effect.num_detectors() == 2 && contrib.effect.dem_outputs.is_empty() { - let direct_targets = - Self::two_detector_direct_targets(&contrib.effect, singleton_set); - if matches!( - two_detector_direct_policy, - TwoDetectorDirectRenderPolicy::PreferRecordedComponents - ) && recorded_component_targets.as_deref() == Some(cached.as_str()) - && cached != &direct_targets - { - ContributionRenderStrategy::RecordedComponents - } else { - ContributionRenderStrategy::TwoDetectorDirect + fn collect_source_graphlike_closure(&self) -> SourceGraphlikeClosure { + let mut graphlike = + SourceGraphlikeClosure::from_primitives(self.collect_source_graphlike_mechanisms()); + + loop { + let before = graphlike.len(); + let index = GraphlikeDecompositionIndex::from_source_closure(&graphlike); + + for contrib in &self.contributions { + let mut parts = Self::source_component_parts(contrib); + let effect = contrib.effect.standard_effect(); + if !contrib.location_indices.is_empty() && !effect.is_empty() { + parts.push(effect); } - } else if contrib.effect.is_hyperedge() { - ContributionRenderStrategy::HyperedgeGraphlike - } else { - ContributionRenderStrategy::EffectDirect - }; - return (cached.clone(), strategy, recorded_component_targets); + + for part in parts { + let origin = part.clone(); + let decomposed = if is_detectable_graphlike_component(&part) { + vec![part] + } else { + index + .find_hyperedge_discovery_decomposition_for_origin(&part, Some(&origin)) + .unwrap_or_else(|| vec![part]) + }; + + for piece in decomposed { + if is_detectable_graphlike_component(&piece) && !piece.is_standard_empty() { + graphlike.insert_derived(piece, &origin); + } + } + } + } + + if graphlike.len() == before { + break; + } } - let effect = contrib.effect.standard_effect(); - let (targets, strategy) = if let Some((x_effect, z_effect)) = - contrib.decomposition_components() - { - let x_graphlike = x_effect.is_empty() || x_effect.is_graphlike(); - let z_graphlike = z_effect.is_empty() || z_effect.is_graphlike(); - - if !x_effect.is_empty() && !z_effect.is_empty() && x_graphlike && z_graphlike { - let x_parts = - Self::maybe_maximally_decompose_parts(vec![x_effect.clone()], singleton_set); - let z_parts = - Self::maybe_maximally_decompose_parts(vec![z_effect.clone()], singleton_set); - let targets = x_parts - .iter() - .chain(z_parts.iter()) - .map(format_mechanism_targets) - .filter(|targets| !targets.is_empty()) - .collect::>() - .join(" ^ "); - let targets = if targets.is_empty() { - String::new() - } else { - targets - }; - (targets, ContributionRenderStrategy::SourceComponents) - } else if effect.num_detectors() == 2 && effect.dem_outputs.is_empty() { - let direct_targets = Self::two_detector_direct_targets(&effect, singleton_set); + graphlike + } + + fn source_graphlike_decompose_parts( + parts: Vec, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + let mut out = Vec::new(); + for part in parts { + if part.is_empty() { + continue; + } + + let decomposed = if is_detectable_graphlike_component(&part) { + vec![part] + } else if let Some(index) = source_graphlike_index { + index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &part, + Some(&part), + source_graphlike_path_cache, + ) + .unwrap_or_else(|| vec![part]) + } else { + vec![part] + }; + + out.extend(Self::maybe_maximally_decompose_parts( + decomposed, + singleton_set, + )); + } + out + } + + fn source_graphlike_decompose_recorded_or_full_effect( + effect: &FaultMechanism, + parts: Vec, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + let recorded_parts = Self::source_graphlike_decompose_parts( + parts, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + let has_pure_logical_component = parts_have_pure_logical_component(&recorded_parts); + if !has_pure_logical_component && parts_are_detectable_graphlike(&recorded_parts) { + return recorded_parts; + } + + let full_effect_parts = Self::source_graphlike_decompose_full_effect_or_raw( + effect, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + if has_pure_logical_component || parts_are_detectable_graphlike(&full_effect_parts) { + return full_effect_parts; + } + + recorded_parts + } + + fn format_decomposed_parts(parts: Vec) -> String { + Self::parity_reduce_decomposed_parts(parts) + .iter() + .map(format_mechanism_targets) + .filter(|targets| !targets.is_empty()) + .collect::>() + .join(" ^ ") + } + + fn parity_reduce_decomposed_parts(parts: Vec) -> Vec { + parity_reduce_mechanisms(parts) + } + + fn recorded_component_targets( + contrib: &FaultContribution, + singleton_set: Option<&SingletonDecompositionIndex>, + ) -> Option { + let parts = contrib.source_component_effects()?; + let targets = Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + parts.into_iter().filter(|part| !part.is_empty()).collect(), + singleton_set, + )); + if targets.is_empty() { + None + } else { + Some(targets) + } + } + + fn two_detector_direct_targets( + effect: &FaultMechanism, + singleton_set: Option<&SingletonDecompositionIndex>, + ) -> String { + Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set) + .iter() + .map(format_mechanism_targets) + .collect::>() + .join(" ^ ") + } + + // Irreducible rendering inputs: the contribution plus several read-only + // decomposition indices/policies and two distinct `&mut` caches (path-search + // and render). A params struct would only relocate the same set behind a + // lifetime-laden wrapper without making any call site clearer. + #[allow(clippy::too_many_arguments)] + fn contribution_render_details( + contrib: &FaultContribution, + graphlike_index: &GraphlikeDecompositionIndex, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + two_detector_direct_policy: TwoDetectorDirectRenderPolicy, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + source_graphlike_path_cache: &mut GraphPathSearchCache, + cache: &mut BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + >, + ) -> (String, ContributionRenderStrategy, Option) { + let recorded_component_targets = Self::recorded_component_targets(contrib, singleton_set); + let key = ( + contrib.effect.clone(), + contrib.source_type.clone(), + recorded_component_targets.clone(), + ); + if let Some((cached_targets, cached_strategy)) = cache.get(&key) { + return ( + cached_targets.clone(), + *cached_strategy, + recorded_component_targets, + ); + } + + let effect = contrib.effect.standard_effect(); + let (targets, strategy) = if let Some((x_effect, z_effect)) = + contrib.decomposition_components() + { + if !x_effect.is_empty() && !z_effect.is_empty() { + let source_parts = Self::source_graphlike_decompose_parts( + vec![x_effect.clone(), z_effect.clone()], + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + let parts = if parts_have_pure_logical_component(&source_parts) { + Self::source_graphlike_decompose_full_effect_or_raw( + &effect, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ) + } else { + source_parts + }; + let targets = Self::format_decomposed_parts(parts); + (targets, ContributionRenderStrategy::SourceComponents) + } else if effect.num_detectors() == 2 && effect.dem_outputs.is_empty() { + let direct_targets = Self::two_detector_direct_targets(&effect, singleton_set); if matches!( two_detector_direct_policy, TwoDetectorDirectRenderPolicy::PreferRecordedComponents @@ -3722,15 +5727,65 @@ impl DetectorErrorModel { ) } } else if effect.is_hyperedge() { - if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { - ( - Self::maybe_maximally_decompose_parts(decomp, singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), - ContributionRenderStrategy::HyperedgeGraphlike, - ) + if let Some(parts) = contrib.source_component_effects() { + let targets = Self::format_decomposed_parts( + Self::source_graphlike_decompose_recorded_or_full_effect( + &effect, + parts.into_iter().collect(), + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ), + ); + (targets, ContributionRenderStrategy::RecordedComponents) + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + if let Some(index) = source_graphlike_index { + if let Some(decomp) = index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &effect, + Some(&effect), + source_graphlike_path_cache, + ) + { + ( + Self::format_decomposed_parts( + Self::maybe_maximally_decompose_parts(decomp, singleton_set), + ), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } } else { ( format_mechanism_targets(&effect), @@ -3739,11 +5794,10 @@ impl DetectorErrorModel { } } else { ( - Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + vec![effect.clone()], + singleton_set, + )), ContributionRenderStrategy::EffectDirect, ) } @@ -3778,15 +5832,66 @@ impl DetectorErrorModel { ) } } else if effect.is_hyperedge() { - if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { - ( - Self::maybe_maximally_decompose_parts(decomp, singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), - ContributionRenderStrategy::HyperedgeGraphlike, - ) + if let Some(parts) = contrib.source_component_effects() { + let targets = Self::format_decomposed_parts( + Self::source_graphlike_decompose_recorded_or_full_effect( + &effect, + parts.into_iter().collect(), + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ), + ); + (targets, ContributionRenderStrategy::RecordedComponents) + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + if let Some(index) = source_graphlike_index { + if let Some(decomp) = index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &effect, + Some(&effect), + source_graphlike_path_cache, + ) + { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } } else { ( format_mechanism_targets(&effect), @@ -3795,31 +5900,42 @@ impl DetectorErrorModel { } } else { ( - Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + vec![effect.clone()], + singleton_set, + )), ContributionRenderStrategy::EffectDirect, ) }; - cache.insert(key, targets.clone()); + cache.insert(key, (targets.clone(), strategy)); (targets, strategy, recorded_component_targets) } + // Thin forwarder to `contribution_render_details`; carries the same + // irreducible parameter set (see that method's note). + #[allow(clippy::too_many_arguments)] fn contribution_targets( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, singleton_set: Option<&SingletonDecompositionIndex>, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, - cache: &mut BTreeMap<(FaultMechanism, FaultSourceType), String>, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + source_graphlike_path_cache: &mut GraphPathSearchCache, + cache: &mut BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + >, ) -> String { Self::contribution_render_details( contrib, graphlike_index, + source_graphlike_index, singleton_set, two_detector_direct_policy, + hyperedge_policy, + source_graphlike_path_cache, cache, ) .0 @@ -3844,15 +5960,32 @@ impl DetectorErrorModel { /// because the edge is already graphlike and extra L0 terms can change /// decoder behavior without adding new information. /// - /// Hyperedges (3+ detectors) are decomposed into graphlike forms when - /// possible. Mechanisms with up to 2 detectors are already graphlike even + /// Hyperedges (3+ detectors) are decomposed only when source-tracked + /// component structure justifies the split. Residual hyperedges remain + /// hyperedges. Mechanisms with up to 2 detectors are already graphlike even /// when they carry multiple DEM outputs. #[must_use] fn to_string_decomposed_inner( &self, maximal_decomposition: bool, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, ) -> String { + let profile_enabled = std::env::var_os("PECOS_DEM_RENDER_PROFILE").is_some(); + let profile_start = std::time::Instant::now(); + let mut profile_last = profile_start; + let profile_step = |label: &str, last: &mut std::time::Instant| { + if profile_enabled { + let now = std::time::Instant::now(); + eprintln!( + "[pecos-dem-render] {label}: step={:.3}s total={:.3}s", + now.duration_since(*last).as_secs_f64(), + now.duration_since(profile_start).as_secs_f64(), + ); + *last = now; + } + }; + let mut lines = Vec::new(); // Add detector coordinate annotations @@ -3868,40 +6001,91 @@ impl DetectorErrorModel { for obs in &self.observables { lines.push(format!("logical_observable L{}", obs.id)); } + profile_step("annotations", &mut profile_last); - let graphlike_set = self.collect_graphlike_mechanisms(); + let graphlike_set = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + self.collect_graphlike_mechanisms() + } else { + BTreeSet::new() + }; let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); + profile_step("global_graphlike_index", &mut profile_last); + let source_graphlike_closure = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + self.collect_source_graphlike_closure() + } else { + SourceGraphlikeClosure::default() + }; + if profile_enabled { + eprintln!( + "[pecos-dem-render] source_graphlike_closure_size={}", + source_graphlike_closure.len() + ); + } + profile_step("source_graphlike_closure", &mut profile_last); + let source_graphlike_index = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + Some(GraphlikeDecompositionIndex::from_source_closure( + &source_graphlike_closure, + )) + } else { + None + }; + profile_step("source_graphlike_index", &mut profile_last); let singleton_set = maximal_decomposition.then(|| self.collect_singleton_index()); + profile_step("singleton_index", &mut profile_last); let mut by_targets: BTreeMap = BTreeMap::new(); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); - - let mut add_targets = |targets: String, probability: f64| { - if targets.is_empty() || probability <= 0.0 { - return; - } - by_targets - .entry(targets) - .and_modify(|p| *p = combine_independent_probs(*p, probability)) - .or_insert(probability); - }; + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); // Process each tracked contribution individually, then regroup identical // decomposed outputs. Rewriting each error class before merging keeps // source-aware decompositions stable. + let mut rendered_contribs = 0usize; for contrib in &self.contributions { if contrib.effect.is_empty() || contrib.probability <= 0.0 { continue; } + rendered_contribs += 1; let targets = Self::contribution_targets( contrib, &graphlike_index, + source_graphlike_index.as_ref(), singleton_set.as_ref(), two_detector_direct_policy, + hyperedge_policy, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); - add_targets(targets, contrib.probability); + if !targets.is_empty() && contrib.probability > 0.0 { + by_targets + .entry(targets) + .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) + .or_insert(contrib.probability); + } + if profile_enabled && rendered_contribs.is_multiple_of(5000) { + let now = std::time::Instant::now(); + eprintln!( + "[pecos-dem-render] rendered_contributions={} render_cache={} path_cache={} target_buckets={} total={:.3}s", + rendered_contribs, + rendered_targets_cache.len(), + source_graphlike_path_cache.len(), + by_targets.len(), + now.duration_since(profile_start).as_secs_f64(), + ); + } } + profile_step("render_contributions", &mut profile_last); for (targets, total_prob) in by_targets { if !targets.is_empty() && total_prob > 0.0 { @@ -3912,13 +6096,65 @@ impl DetectorErrorModel { )); } } + profile_step("format_output", &mut profile_last); lines.join("\n") } #[must_use] pub fn to_string_decomposed(&self) -> String { - self.to_string_decomposed_inner(false, TwoDetectorDirectRenderPolicy::KeepDirect) + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) + } + + /// Converts the DEM to a source-decomposed string. + /// + /// This is an explicit alias for [`Self::to_string_decomposed`]. The + /// renderer uses only decomposition structure attached to the original + /// fault source (for example Y=X^Z components and recorded per-location + /// components for multi-qubit sources). Hyperedges without a + /// source-legitimate decomposition remain hyperedges. + #[must_use] + pub fn to_string_source_decomposed(&self) -> String { + self.to_string_decomposed() + } + + /// Converts the DEM to a source-informed graphlike decomposition. + /// + /// This renderer first uses source-carried component structure (for + /// example Y=X^Z and recorded per-location components). If a source + /// component is still a hyperedge, it follows Stim's intra/inter-channel + /// decomposition strategy to a fixed point: use graphlike mechanisms that + /// appear as source-carried components or full source-tracked alternatives + /// in this DEM, then introduce graphlike remnants when needed and make + /// those available to later decompositions. Residual hyperedges remain + /// hyperedges if this source-informed graphlike closure cannot explain them. + #[must_use] + pub fn to_string_source_graphlike_decomposed(&self) -> String { + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents, + ) + } + + /// Converts the DEM to a graphlike-search decomposed string. + /// + /// This keeps the historical compatibility behavior that may decompose a + /// residual hyperedge by searching for graphlike mechanisms elsewhere in + /// the DEM. It is intentionally separate from the source-decomposed path: + /// use it only for representation experiments, not as proof that the source + /// mechanism itself has graphlike components. + #[must_use] + pub fn to_string_graphlike_search_decomposed(&self) -> String { + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch, + ) } /// Converts the DEM to decomposed format with an explicit direct-2det @@ -3928,7 +6164,11 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> String { - self.to_string_decomposed_inner(false, two_detector_direct_policy) + self.to_string_decomposed_inner( + false, + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Converts the DEM to a maximally decomposed graphlike form when possible. @@ -3943,7 +6183,11 @@ impl DetectorErrorModel { /// resulting matching graph. #[must_use] pub fn to_string_decomposed_maximally(&self) -> String { - self.to_string_decomposed_inner(true, TwoDetectorDirectRenderPolicy::KeepDirect) + self.to_string_decomposed_inner( + true, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Converts the DEM to a maximally decomposed graphlike form with an @@ -3953,7 +6197,11 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> String { - self.to_string_decomposed_inner(true, two_detector_direct_policy) + self.to_string_decomposed_inner( + true, + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Collects all graphlike mechanisms from contributions. @@ -3964,7 +6212,7 @@ impl DetectorErrorModel { let mut graphlike = BTreeSet::new(); for contrib in &self.contributions { let standard = contrib.effect.standard_effect(); - if !standard.is_standard_empty() && standard.is_graphlike() { + if !standard.is_standard_empty() && is_detectable_graphlike_component(&standard) { graphlike.insert(standard); } } @@ -5043,6 +7291,38 @@ mod tests { assert!(!maximal.contains("error(0.01) D0 D1")); } + #[test] + fn test_terminal_graphlike_decomposed_uses_min_coordinate_terminal_pairs() { + let mut dem = DetectorErrorModel::new(); + + dem.add_detector(DetectorDef::new(0).with_coords([0.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(1).with_coords([10.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(2).with_coords([1.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(3).with_coords([11.0, 0.0, 0.0])); + dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1, 2, 3], []), 0.01); + + let projected = dem.to_string_terminal_graphlike_decomposed(); + + assert!(projected.contains("error(0.01) D0 D2 ^ D1 D3")); + assert!(!projected.contains("D0 D1 ^ D2 D3")); + } + + #[test] + fn test_terminal_graphlike_decomposed_attaches_observable_to_terminal_component() { + let mut dem = DetectorErrorModel::new(); + + dem.add_detector(DetectorDef::new(0).with_coords([0.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(1).with_coords([1.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(2).with_coords([10.0, 0.0, 0.0])); + dem.add_dem_output(DemOutput::new(0)); + dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1, 2], [0]), 0.01); + + let projected = dem.to_string_terminal_graphlike_decomposed(); + + assert!(projected.contains("logical_observable L0")); + assert!(projected.contains("error(0.01) D0 D1 ^ D2 L0")); + } + #[test] fn test_contribution_effect_summaries_include_graphlike_decomposable_count() { let mut dem = DetectorErrorModel::new(); @@ -5280,68 +7560,1088 @@ mod tests { } #[test] - fn test_direct_with_source_components_marks_one_sided_component_sources() { - let effect = FaultMechanism::from_unsorted([7, 11], std::iter::empty()); - let first = effect.clone(); - let second = FaultMechanism::new(); + fn test_decomposed_render_uses_recorded_graphlike_components_for_direct_hyperedge() { + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); - let contribution = FaultContribution::direct_with_source_components( - effect.clone(), + dem.add_direct_contribution_with_source_components( + effect, 0.01, SourceMetadata::new( - &[3, 4], - &[Pauli::Z, Pauli::I], + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], &[GateType::CX, GateType::CX], &[false, false], ), DirectSourceComponents::new(&first, &second), ); - assert!(contribution.is_direct()); - assert!(matches!( - contribution.source_type, - FaultSourceType::DirectOneSidedComponent - )); + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 ^ D2 D3")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); assert_eq!( - contribution.direct_source_family, - Some(DirectSourceFamily::TwoLocationOneSidedComponent) + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents ); - let (left, right) = contribution - .direct_component_effects() - .expect("expected direct component effects"); - assert_eq!(left, effect); - assert!(right.is_empty()); assert_eq!( - contribution.source_gate_types.as_slice(), - &[GateType::CX, GateType::CX] + records[0].recorded_component_targets.as_deref(), + Some("D0 D1 ^ D2 D3") ); - assert_eq!(contribution.source_before_flags.as_slice(), &[false, false]); } #[test] - fn test_add_y_decomposed_contribution_with_source_routes_metadata_to_direct() { + fn test_decomposed_render_preserves_recorded_hyperedge_components() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3, 4], std::iter::empty()); + let effect = first.xor(&second); let mut dem = DetectorErrorModel::new(); - let x = FaultMechanism::new(); - let z = FaultMechanism::from_unsorted([1, 44], std::iter::empty()); - dem.add_y_decomposed_contribution_with_source( - &x, - &z, - 0.02, - SourceMetadata::new(&[7], &[Pauli::Y], &[GateType::H], &[false]), + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, ); - - let contributions = dem.contributions_for_effect(&[1, 44], &[]); - assert_eq!(contributions.len(), 1); - let contribution = &contributions[0]; - assert!(matches!(contribution.source_type, FaultSourceType::Direct)); - assert_eq!(contribution.location_indices.as_slice(), &[7]); - assert_eq!(contribution.paulis.as_slice(), &[Pauli::Y]); - assert_eq!(contribution.source_gate_types.as_slice(), &[GateType::H]); - assert_eq!(contribution.source_before_flags.as_slice(), &[false]); - assert_eq!( + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let records = dem.contribution_render_records(); + let record = records + .iter() + .find(|record| (record.contribution.probability - 0.01).abs() < 1e-12) + .expect("expected recorded-component contribution"); + + assert_eq!( + record.render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!(record.rendered_targets, "D0 D1 D2 ^ D3 D4"); + } + + #[test] + fn test_decomposed_render_cache_distinguishes_recorded_components() { + let effect = FaultMechanism::from_unsorted([0, 1, 2, 3], std::iter::empty()); + let a_first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let a_second = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let b_first = FaultMechanism::from_unsorted([0, 2], std::iter::empty()); + let b_second = FaultMechanism::from_unsorted([1, 3], std::iter::empty()); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect.clone(), + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&a_first, &a_second), + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.02, + SourceMetadata::new( + &[5usize, 6usize], + &[Pauli::Z, Pauli::X], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&b_first, &b_second), + ); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].rendered_targets, "D0 D1 ^ D2 D3"); + assert_eq!(records[1].rendered_targets, "D0 D2 ^ D1 D3"); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[1].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 ^ D2 D3")); + assert!(decomposed.contains("error(0.02) D0 D2 ^ D1 D3")); + } + + #[test] + fn test_decomposed_render_uses_recorded_hyperedge_components_without_global_search() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 D2 ^ D3")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[0].recorded_component_targets.as_deref(), + Some("D0 D1 D2 ^ D3") + ); + } + + #[test] + fn test_graphlike_search_decomposed_is_explicit_compatibility_path() { + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()), + 0.01, + ); + + let source_decomposed = dem.to_string_decomposed(); + assert!(source_decomposed.contains("error(0.01) D0 D1 D2")); + assert!(!source_decomposed.contains("error(0.01) D0 D1 ^ D2")); + + let graphlike_search = dem.to_string_graphlike_search_decomposed(); + assert!(graphlike_search.contains("error(0.01) D0 D1 ^ D2")); + } + + #[test] + fn test_source_graphlike_decomposed_uses_source_component_graphlike_pieces() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + // These graphlike pieces are not arbitrary full effects; they are + // carried as source components on tracked contributions. + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + SourceMetadata::new( + &[10usize, 11usize], + &[Pauli::X, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new( + &FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + &FaultMechanism::new(), + ), + ); + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + SourceMetadata::new( + &[12usize, 13usize], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new( + &FaultMechanism::from_unsorted([2], std::iter::empty()), + &FaultMechanism::new(), + ), + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 ^ D3")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 ^ D2 ^ D3")); + } + + #[test] + fn test_source_graphlike_decomposed_rejects_global_only_decomposition() { + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()), + 0.01, + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 D2")); + assert!(!source_graphlike.contains("error(0.01) D0 D1 ^ D2")); + + let graphlike_search = dem.to_string_graphlike_search_decomposed(); + assert!(graphlike_search.contains("error(0.01) D0 D1 ^ D2")); + } + + #[test] + fn test_source_graphlike_decomposed_uses_source_remnant_edges() { + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([1, 2], std::iter::empty()), + 0.001, + SourceMetadata::new(&[10usize], &[Pauli::X], &[GateType::H], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([0, 1, 2], [0]), + 0.01, + SourceMetadata::new(&[11usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 L0")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!( + source_graphlike.contains("error(0.01) D1 D2 ^ D0 L0") + || source_graphlike.contains("error(0.01) D0 L0 ^ D1 D2") + ); + } + + #[test] + fn test_source_graphlike_decomposed_uses_exact_detector_graph_paths() { + let mut dem = DetectorErrorModel::new(); + let graphlike_parts = [ + FaultMechanism::from_unsorted([10, 13], std::iter::empty()), + FaultMechanism::from_unsorted([13, 40], std::iter::empty()), + FaultMechanism::from_unsorted([651, 655], std::iter::empty()), + FaultMechanism::from_unsorted([658, 661], std::iter::empty()), + FaultMechanism::from_unsorted([661, 664], std::iter::empty()), + FaultMechanism::from_unsorted([659], std::iter::empty()), + ]; + for (idx, part) in graphlike_parts.iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = graphlike_parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([10, 40, 651, 655, 658, 659, 664], std::iter::empty(),) + ); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[99usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for target hyperedge"); + for expected in [ + "D10 D13", + "D13 D40", + "D651 D655", + "D658 D661", + "D659", + "D661 D664", + ] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!( + !target_line.contains("D10 D40 D651"), + "target hyperedge should be split through exact graph paths: {target_line}", + ); + } + + #[test] + fn test_source_graphlike_path_decomposition_matches_logical_frame() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], [0]); + let third = FaultMechanism::from_unsorted([3], std::iter::empty()); + for (idx, part) in [&first, &second, &third].into_iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third); + assert_eq!(effect, FaultMechanism::from_unsorted([0, 2, 3], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[9usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for logical target"); + assert!(target_line.contains("D0 D1")); + assert!(target_line.contains("D1 D2 L0")); + assert!(target_line.contains("D3")); + assert!(!target_line.contains("D0 D2 D3 L0")); + } + + #[test] + fn test_source_graphlike_path_decomposition_rejects_pure_logical_fixup() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], std::iter::empty()); + let third = FaultMechanism::from_unsorted([3], std::iter::empty()); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + for (idx, part) in [&first, &second, &third, &pure_logical] + .into_iter() + .enumerate() + { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third).xor(&pure_logical); + assert_eq!(effect, FaultMechanism::from_unsorted([0, 2, 3], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[9usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + assert!(target_line.contains("L0")); + let (_, targets) = target_line + .split_once(") ") + .expect("expected error line target separator"); + for component in targets.split(" ^ ") { + let has_detector = component + .split_whitespace() + .any(|target| target.starts_with('D')); + let has_logical = component + .split_whitespace() + .any(|target| target.starts_with('L')); + assert!( + has_detector || !has_logical, + "logical component must be detector-bearing in {target_line}", + ); + } + } + + #[test] + fn test_source_graphlike_decomposition_rejects_pure_logical_singleton_piece() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2], std::iter::empty()); + let third = FaultMechanism::from_unsorted([121], std::iter::empty()); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + for (idx, part) in [&first, &second, &third, &pure_logical] + .into_iter() + .enumerate() + { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third).xor(&pure_logical); + assert_eq!(effect, FaultMechanism::from_unsorted([1, 2, 121], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[20usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + assert!( + !target_line.contains("L0 ^"), + "pure logical must not be emitted as a decomposed component: {target_line}", + ); + assert!( + !target_line.contains("^ L0"), + "pure logical must not be emitted as a decomposed component: {target_line}", + ); + assert!( + target_line.contains("D1 D2 D121 L0"), + "effect should remain as the raw source hyperedge when no detector-bearing graphlike decomposition exists: {target_line}", + ); + } + + #[test] + fn test_source_graphlike_recorded_components_reject_pure_logical_component() { + let mut dem = DetectorErrorModel::new(); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], [0]); + let components = [pure_logical.clone(), first.clone(), second.clone()]; + let effect = components + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([0, 2], std::iter::empty()) + ); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[10usize, 11usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::from_slice(&components), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + let (_, targets) = target_line + .split_once(") ") + .expect("expected error line target separator"); + for component in targets.split(" ^ ") { + let has_detector = component + .split_whitespace() + .any(|target| target.starts_with('D')); + let has_logical = component + .split_whitespace() + .any(|target| target.starts_with('L')); + assert!( + has_detector || !has_logical, + "logical component must be detector-bearing in {target_line}", + ); + } + } + + #[test] + fn test_source_graphlike_path_decomposition_handles_boundary_cluster() { + let mut dem = DetectorErrorModel::new(); + let graphlike_parts = [ + FaultMechanism::from_unsorted([14, 40], std::iter::empty()), + FaultMechanism::from_unsorted([40, 44], std::iter::empty()), + FaultMechanism::from_unsorted([654], [0]), + FaultMechanism::from_unsorted([662], std::iter::empty()), + FaultMechanism::from_unsorted([663, 666], std::iter::empty()), + FaultMechanism::from_unsorted([668, 671], std::iter::empty()), + FaultMechanism::from_unsorted([671], std::iter::empty()), + ]; + for (idx, part) in graphlike_parts.iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = graphlike_parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([14, 44, 654, 662, 663, 666, 668], [0]) + ); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[99usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for boundary cluster"); + for expected in [ + "D14 D40", + "D40 D44", + "D654 L0", + "D662", + "D663 D666", + "D668 D671", + "D671", + ] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!(!target_line.contains("D14 D44 D654")); + } + + #[test] + fn test_source_graphlike_closure_learns_full_source_alternatives() { + let mut dem = DetectorErrorModel::new(); + + let hidden_pair = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let first = FaultMechanism::from_unsorted([2, 3, 4], std::iter::empty()); + let second = FaultMechanism::from_unsorted([0, 1, 2, 3, 4], std::iter::empty()); + assert_eq!(first.xor(&second), hidden_pair); + + dem.add_direct_contribution_with_source_components( + hidden_pair, + 0.001, + SourceMetadata::new( + &[20usize, 21usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([6], std::iter::empty()), + 0.001, + SourceMetadata::new(&[22usize], &[Pauli::X], &[GateType::H], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([0, 1, 6], std::iter::empty()), + 0.01, + SourceMetadata::new(&[23usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D6")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 ^ D6")); + } + + #[test] + fn test_source_graphlike_closure_promotes_remnant_edges_for_later_paths() { + let mut dem = DetectorErrorModel::new(); + + for (loc, effect) in [ + ( + 10usize, + FaultMechanism::from_unsorted([100, 101], std::iter::empty()), + ), + ( + 11usize, + FaultMechanism::from_unsorted([102, 103], std::iter::empty()), + ), + ( + 12usize, + FaultMechanism::from_unsorted([200, 201], std::iter::empty()), + ), + ] { + dem.add_direct_contribution_with_source( + effect, + 0.001, + SourceMetadata::new(&[loc], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + // These two source mechanisms introduce graphlike remnant edges D2-D28 + // and D28-D32. A later source mechanism needs both as a detector-graph + // path from D2 to D32. + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([2, 28, 100, 101], std::iter::empty()), + 0.002, + SourceMetadata::new(&[20usize], &[Pauli::X], &[GateType::SX], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([28, 32, 102, 103], std::iter::empty()), + 0.002, + SourceMetadata::new(&[21usize], &[Pauli::X], &[GateType::SX], &[false]), + ); + + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([2, 32, 200, 201], std::iter::empty()), + 0.01, + SourceMetadata::new(&[22usize], &[Pauli::Z], &[GateType::SZZ], &[false]), + ); + + let closure = dem.collect_source_graphlike_closure(); + assert!( + closure + .mechanisms + .contains(&FaultMechanism::from_unsorted([2, 28], std::iter::empty(),)) + ); + assert!( + closure + .mechanisms + .contains(&FaultMechanism::from_unsorted([28, 32], std::iter::empty(),)) + ); + let target = FaultMechanism::from_unsorted([2, 32, 200, 201], std::iter::empty()); + let index = GraphlikeDecompositionIndex::from_source_closure(&closure); + let self_remnant = FaultMechanism::from_unsorted([2, 32], std::iter::empty()); + assert!( + !index.candidate_allowed(&self_remnant, Some(&target)), + "self remnant provenance: primitive={} origins={:?}", + closure.primitive.contains(&self_remnant), + closure.derived_origins.get(&self_remnant), + ); + let graph_path = index + .find_graph_path_decomposition(&target, Some(&target)) + .expect("expected promoted remnant edges to produce a graph path"); + assert!( + graph_path.contains(&FaultMechanism::from_unsorted([2, 28], std::iter::empty(),)), + "{graph_path:?}", + ); + assert!( + graph_path.contains(&FaultMechanism::from_unsorted([28, 32], std::iter::empty(),)), + "{graph_path:?}", + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected later hyperedge to decompose through promoted remnant edges"); + for expected in ["D2 D28", "D28 D32", "D200 D201"] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!(!target_line.contains("D2 D32 D200 D201")); + } + + #[test] + fn test_source_graphlike_recorded_components_can_use_full_effect_decomposition() { + let mut dem = DetectorErrorModel::new(); + + for (loc, effect) in [ + ( + 30usize, + FaultMechanism::from_unsorted([0, 3], std::iter::empty()), + ), + ( + 31usize, + FaultMechanism::from_unsorted([1, 4], std::iter::empty()), + ), + ( + 32usize, + FaultMechanism::from_unsorted([2, 5], std::iter::empty()), + ), + ] { + dem.add_direct_contribution_with_source( + effect, + 0.001, + SourceMetadata::new(&[loc], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3, 4, 5], std::iter::empty()); + let effect = first.xor(&second); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[40usize, 41usize], + &[Pauli::X, Pauli::X], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 ^ D3 D4 D5")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("D0 D3")); + assert!(source_graphlike.contains("D1 D4")); + assert!(source_graphlike.contains("D2 D5")); + assert!(!source_graphlike.contains("error(0.01) D0 D1 D2 ^ D3 D4 D5")); + } + + #[test] + fn test_decomposed_render_cancels_duplicate_components_by_parity() { + let mut dem = DetectorErrorModel::new(); + let repeated = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let survivor = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let effect = survivor.clone(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[50usize, 51usize, 52usize], + &[Pauli::X, Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ, GateType::SZZ], + &[false, false, false], + ), + DirectSourceComponents::from_slice(&[repeated.clone(), repeated, survivor]), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.01) D2 D3")); + assert!(!source_decomposed.contains("D0 D1")); + } + + #[test] + fn test_spp_source_components_preserve_recorded_hypergraph_structure() { + for gate_type in [GateType::SZZ, GateType::SZZdg] { + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2, 3, 4], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[gate_type, gate_type], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.01) D0 D1 ^ D2 D3 D4")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[0].contribution.source_gate_types.as_slice(), + &[gate_type, gate_type] + ); + } + } + + #[test] + fn test_spp_y_source_decomposition_preserves_hyperedge_branch() { + let x_effect = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let z_effect = FaultMechanism::from_unsorted([3, 4], std::iter::empty()); + let mut dem = DetectorErrorModel::new(); + + dem.add_y_decomposed_contribution_with_source( + &x_effect, + &z_effect, + 0.02, + SourceMetadata::new( + &[5usize, 6usize], + &[Pauli::Y, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.02) D0 D1 D2 ^ D3 D4")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::SourceComponents + ); + assert_eq!( + records[0].contribution.source_gate_types.as_slice(), + &[GateType::SZZ, GateType::SZZ] + ); + } + + #[test] + fn test_direct_with_source_components_marks_one_sided_component_sources() { + let effect = FaultMechanism::from_unsorted([7, 11], std::iter::empty()); + let first = effect.clone(); + let second = FaultMechanism::new(); + + let contribution = FaultContribution::direct_with_source_components( + effect.clone(), + 0.01, + SourceMetadata::new( + &[3, 4], + &[Pauli::Z, Pauli::I], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + assert!(contribution.is_direct()); + assert!(matches!( + contribution.source_type, + FaultSourceType::DirectOneSidedComponent + )); + assert_eq!( + contribution.direct_source_family, + Some(DirectSourceFamily::TwoLocationOneSidedComponent) + ); + let (left, right) = contribution + .direct_component_effects() + .expect("expected direct component effects"); + assert_eq!(left, effect); + assert!(right.is_empty()); + assert_eq!( + contribution.source_gate_types.as_slice(), + &[GateType::CX, GateType::CX] + ); + assert_eq!(contribution.source_before_flags.as_slice(), &[false, false]); + } + + #[test] + fn test_replacement_branch_source_metadata_is_preserved() { + let effect = FaultMechanism::from_unsorted([7, 11], std::iter::empty()); + let first = effect.clone(); + let second = FaultMechanism::new(); + + let contribution = FaultContribution::direct_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3, 4], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationReplacementBranchImpact) + .with_replacement_branch(), + DirectSourceComponents::new(&first, &second), + ); + + assert!(contribution.replacement_branch); + assert_eq!( + contribution.direct_source_family, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact) + ); + + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([7, 11], std::iter::empty()), + 0.02, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationReplacementBranchImpact) + .with_replacement_branch(), + DirectSourceComponents::new(&first, &second), + ); + let contributions = dem.contributions_for_effect(&[7, 11], &[]); + assert_eq!(contributions.len(), 1); + assert!(contributions[0].replacement_branch); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact) + ); + } + + #[test] + fn test_add_y_decomposed_contribution_with_source_routes_metadata_to_direct() { + let mut dem = DetectorErrorModel::new(); + let x = FaultMechanism::new(); + let z = FaultMechanism::from_unsorted([1, 44], std::iter::empty()); + + dem.add_y_decomposed_contribution_with_source( + &x, + &z, + 0.02, + SourceMetadata::new(&[7], &[Pauli::Y], &[GateType::H], &[false]), + ); + + let contributions = dem.contributions_for_effect(&[1, 44], &[]); + assert_eq!(contributions.len(), 1); + let contribution = &contributions[0]; + assert!(matches!(contribution.source_type, FaultSourceType::Direct)); + assert_eq!(contribution.location_indices.as_slice(), &[7]); + assert_eq!(contribution.paulis.as_slice(), &[Pauli::Y]); + assert_eq!(contribution.source_gate_types.as_slice(), &[GateType::H]); + assert_eq!(contribution.source_before_flags.as_slice(), &[false]); + assert_eq!( contribution.direct_source_family, Some(DirectSourceFamily::SingleLocationY) ); } + + #[test] + fn test_omitted_two_qubit_gate_pauli_twirl_for_spp_gates() { + let szz = omitted_two_qubit_gate_pauli_twirl(GateType::SZZ).expect("SZZ is supported"); + let szzdg = + omitted_two_qubit_gate_pauli_twirl(GateType::SZZdg).expect("SZZdg is supported"); + + assert_eq!(szz, BTreeMap::from([("II", 0.5), ("ZZ", 0.5)])); + assert_eq!(szzdg, szz); + } + + #[test] + fn test_omitted_two_qubit_gate_pauli_twirl_for_entanglers() { + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::CX).expect("CX is supported"), + BTreeMap::from([("II", 0.25), ("IX", 0.25), ("ZI", 0.25), ("ZX", 0.25)]), + ); + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::CZ).expect("CZ is supported"), + BTreeMap::from([("II", 0.25), ("IZ", 0.25), ("ZI", 0.25), ("ZZ", 0.25)]), + ); + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::SWAP).expect("SWAP is supported"), + BTreeMap::from([("II", 0.25), ("XX", 0.25), ("YY", 0.25), ("ZZ", 0.25)]), + ); + assert!(omitted_two_qubit_gate_pauli_twirl(GateType::RZZ).is_none()); + } + + #[test] + fn test_two_qubit_replacement_weight_convolves_with_omitted_gate_twirl() { + use pecos_core::pauli::{X, Y, Z}; + + let weights = PauliWeights::with_replacement([(X(0) & X(1), 0.25)], [(X(0) & X(1), 0.75)]); + + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(X(0) & X(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - (0.25 + 0.75 * 0.5)) + .abs() + < 1e-12 + ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(Y(0) & Y(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.75 * 0.5) + .abs() + < 1e-12 + ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(Y(0) & Y(1)), + ReplacementBranchApproximation::BranchImpact, + ) - 0.75 * 0.5) + .abs() + < 1e-12 + ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(X(0) & X(1)), + ReplacementBranchApproximation::IgnoreGateRemoval, + ) - 1.0) + .abs() + < 1e-12 + ); + + let replacement_omits_only = PauliWeights::with_replacement( + [], + [( + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + Vec::new(), + ), + 1.0, + )], + ); + assert!( + (replacement_omits_only.two_qubit_weight_for( + GateType::SZZ, + &(Z(0) & Z(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.5) + .abs() + < 1e-12 + ); + assert_eq!( + replacement_omits_only.replacement_branch_impact_weights(GateType::SZZ), + BTreeMap::from([("ZZ".to_string(), 0.5)]) + ); + assert_eq!( + replacement_omits_only.replacement_branch_impacts(GateType::SZZ), + vec![ReplacementBranchImpact { + replacement_pauli_label: "II".to_string(), + omitted_gate_twirl_label: "ZZ".to_string(), + pauli_label: "ZZ".to_string(), + relative_probability: 0.5, + }], + ); + + let cx_replacement_identity = PauliWeights::with_replacement([], [(Z(0) & X(1), 1.0)]); + assert!( + (cx_replacement_identity.two_qubit_weight_for( + GateType::CX, + &(Z(0) & X(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.25) + .abs() + < 1e-12 + ); + assert!( + (cx_replacement_identity.two_qubit_weight_for( + GateType::CX, + &Z(0), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.25) + .abs() + < 1e-12 + ); + } } diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index aa77d8e71..6a2b73b3c 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -36,7 +36,7 @@ use super::propagator::{DagFaultAnalyzer, DagPropagator, Direction, Pauli, apply use pecos_core::QubitId; use pecos_simulators::{PauliProp, SymbolicSparseStab}; use smallvec::SmallVec; -use std::collections::BinaryHeap; +use std::collections::{BTreeSet, BinaryHeap}; struct ObservablePropagationWork<'a> { recorder: &'a mut CompoundRecorder, @@ -250,7 +250,7 @@ impl<'a> InfluenceBuilder<'a> { } /// Run symbolic simulation to get measurement correlations. - fn run_symbolic_simulation(&self) -> MeasurementInfo { + pub(crate) fn run_symbolic_simulation(&self) -> MeasurementInfo { let topo_order = self.dag.topological_order(); // Determine number of qubits from the circuit @@ -479,6 +479,7 @@ impl<'a> InfluenceBuilder<'a> { /// Extract fault locations from the propagator. fn extract_locations(propagator: &DagPropagator<'_>) -> Vec { let mut locations = Vec::new(); + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { @@ -497,19 +498,34 @@ impl<'a> InfluenceBuilder<'a> { // Standard circuit noise model: one fault location per gate. // Measurement: before. All others: after. let before = is_measurement; - for &q in &qubits { - // 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; + let location_qubits: Vec = + if gate.gate_type == pecos_quantum::GateType::MeasCrosstalkGlobalPayload { + qubits.iter().copied().for_each(|q| { + prepared_qubits.remove(&q); + }); + let victims = prepared_qubits.iter().copied().collect(); + qubits.iter().copied().for_each(|q| { + prepared_qubits.insert(q); + }); + victims + } else { + qubits.clone() + }; + for q in location_qubits { locations.push(DagSpacetimeLocation { node, qubits: vec![q], before, gate_type: gate.gate_type, - idle_duration, + idle_duration: gate.idle_duration(), }); } + if matches!( + gate.gate_type, + pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + ) { + prepared_qubits.extend(qubits.iter().copied()); + } } } @@ -803,6 +819,7 @@ impl<'a> InfluenceBuilder<'a> { let mut map: std::collections::HashMap<(usize, bool), Vec<(usize, usize)>> = std::collections::HashMap::new(); let mut loc_idx = 0; + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { @@ -816,11 +833,30 @@ impl<'a> InfluenceBuilder<'a> { ); let before = is_measurement; - for q in &gate.qubits { + let location_qubits: Vec = + if gate.gate_type == pecos_quantum::GateType::MeasCrosstalkGlobalPayload { + gate.qubits.iter().copied().for_each(|q| { + prepared_qubits.remove(&q); + }); + let victims = prepared_qubits.iter().copied().collect(); + gate.qubits.iter().copied().for_each(|q| { + prepared_qubits.insert(q); + }); + victims + } else { + gate.qubits.to_vec() + }; + for q in &location_qubits { let qi = q.index(); map.entry((node, before)).or_default().push((qi, loc_idx)); loc_idx += 1; } + if matches!( + gate.gate_type, + pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + ) { + prepared_qubits.extend(gate.qubits.iter().copied()); + } } } @@ -866,11 +902,11 @@ impl<'a> InfluenceBuilder<'a> { } /// Information about measurements from symbolic simulation. -struct MeasurementInfo { - history: pecos_simulators::symbolic_sparse_stab::MeasurementHistory, - node_to_meas_idx: Vec>, +pub(crate) struct MeasurementInfo { + pub(crate) history: pecos_simulators::symbolic_sparse_stab::MeasurementHistory, + pub(crate) node_to_meas_idx: Vec>, #[allow(dead_code)] - num_measurements: usize, + pub(crate) num_measurements: usize, } /// Definition of a detector as XOR of measurements. diff --git a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs index 50d828465..e6bc7eba3 100644 --- a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs +++ b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs @@ -102,10 +102,8 @@ impl LookupDecoder { .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(1, |l| l.idle_duration.max(1)); - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - Some(noise.idle_pauli_probs(duration as f64)) + .map_or(0.0, |l| l.idle_duration.max(0.0)); + Some(noise.idle_pauli_probs(duration)) } else { None }; diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs new file mode 100644 index 000000000..dc1b4e873 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs @@ -0,0 +1,558 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Pauli-frame lookup support for sampling Pauli-twirl masks. +//! +//! Twirl sites are emitted as three positional tracked-Pauli annotations per +//! site: X, Y, and Z. The DEM sampler samples decoder-facing detector and +//! observable bits. This lookup adds the deterministic frame update induced by a +//! user-supplied Pauli mask by XOR-ing precomputed detector/observable rows into +//! sampled shots. + +use super::dem_builder::record_offset_to_absolute_index; +use super::propagator::{Direction, apply_gate}; +use pecos_core::gate_type::GateType; +use pecos_core::{Pauli, PauliString}; +use pecos_quantum::{AnnotationKind, DagCircuit}; +use pecos_simulators::PauliProp; +use std::collections::{BTreeMap, BTreeSet}; +use thiserror::Error; + +type MeasurementRecordMap = BTreeMap>; + +/// Per-shot detector and observable XOR patterns: `(det_xor, obs_xor)`, where +/// row `i` of each is the mask-induced frame flip for shot `i`. +type DetectorObservableXor = (Vec>, Vec>); + +/// Errors returned while building or applying a Pauli-frame lookup. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum PauliFrameLookupError { + /// A tracked-Pauli annotation has no `meta_node` set. + #[error( + "tracked-Pauli annotation is missing its meta_node; cannot determine spacetime position" + )] + MissingMetaNode, + + /// A tracked-Pauli annotation's `meta_node` does not point at a + /// `TrackedPauliMeta` gate in the DAG. + #[error( + "tracked-Pauli annotation references DAG node {meta_node}, which is missing or not a TrackedPauliMeta gate" + )] + MetaNodeNotTrackedPauliMeta { meta_node: usize }, + + /// A measurement gate has malformed measurement IDs. + #[error("measurement node {node} has {meas_ids} measurement id(s) for {qubits} qubit(s)")] + MalformedMeasurementIds { + node: usize, + meas_ids: usize, + qubits: usize, + }, + + /// Detector/observable metadata references a measurement record outside the + /// circuit's measurement range. + #[error( + "{kind} {output} references measurement record offset {record}, but the circuit has {num_measurements} measurement(s)" + )] + InvalidRecordOffset { + kind: &'static str, + output: usize, + record: i32, + num_measurements: usize, + }, + + /// Twirl mask composition requires X/Y/Z triples per site. + #[error("tracked-Pauli count {num_tracked_paulis} is not divisible by 3")] + NonTripletTrackedPaulis { num_tracked_paulis: usize }, + + /// The flat mask buffer length does not match the supplied shape. + #[error("pauli mask buffer has length {len}, expected {expected} for shape ({rows}, {cols})")] + MaskLengthMismatch { + len: usize, + expected: usize, + rows: usize, + cols: usize, + }, + + /// The number of mask rows must match the number of sampled shots. + #[error("pauli mask row count {mask_rows} does not match num_shots {num_shots}")] + MaskShotMismatch { mask_rows: usize, num_shots: usize }, + + /// The number of mask columns must match the number of Pauli-twirl sites. + #[error("pauli mask column count {mask_cols} does not match num_pauli_sites {num_pauli_sites}")] + MaskSiteMismatch { + mask_cols: usize, + num_pauli_sites: usize, + }, + + /// Mask values must use 0=I, 1=X, 2=Y, 3=Z. + #[error("pauli mask value {value} at row {row}, column {col} is outside 0..=3")] + InvalidMaskValue { row: usize, col: usize, value: u8 }, + + /// A sampled output row does not match the lookup dimensions. + #[error("{kind} row {row} has length {actual}, expected {expected}")] + OutputWidthMismatch { + kind: &'static str, + row: usize, + actual: usize, + expected: usize, + }, +} + +/// Deterministic lookup from tracked-Pauli mask values to detector/observable flips. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PauliFrameLookup { + num_pauli_sites: usize, + num_detectors: usize, + num_observables: usize, + detector_rows: Vec>, + observable_rows: Vec>, +} + +impl PauliFrameLookup { + /// Build a Pauli-frame lookup from a DAG circuit and record-based detector + /// and observable definitions. + /// + /// The circuit must carry positional tracked-Pauli annotations. The tracked + /// Paulis are interpreted in groups of three per site, ordered X, Y, Z by + /// the surface-code emitter. + /// + /// # Errors + /// + /// Returns an error when tracked-Pauli metadata is malformed, when tracked + /// annotations are not X/Y/Z triples, or when detector/observable record + /// offsets reference missing measurements. + pub fn from_circuit( + dag: &DagCircuit, + detector_records: &[Vec], + observable_records: &[Vec], + ) -> Result { + let tracked_annotations: Vec<&pecos_quantum::PauliAnnotation> = dag + .annotations() + .iter() + .filter(|ann| matches!(ann.kind, AnnotationKind::TrackedPauli)) + .collect(); + let mut meta_nodes: Vec = dag + .nodes() + .into_iter() + .filter(|&node| { + dag.gate(node) + .is_some_and(|gate| gate.gate_type == GateType::TrackedPauliMeta) + }) + .collect(); + meta_nodes.sort_unstable(); + + if tracked_annotations.len() != meta_nodes.len() { + return Err(PauliFrameLookupError::MissingMetaNode); + } + let tracked: Vec<(&pecos_quantum::PauliAnnotation, usize)> = + tracked_annotations.into_iter().zip(meta_nodes).collect(); + if !tracked.len().is_multiple_of(3) { + return Err(PauliFrameLookupError::NonTripletTrackedPaulis { + num_tracked_paulis: tracked.len(), + }); + } + + let topo_order = dag.topological_order(); + let topo_positions: BTreeMap = topo_order + .iter() + .enumerate() + .map(|(pos, &node)| (node, pos)) + .collect(); + let (measurement_records, num_measurements) = measurement_records_by_node(dag)?; + let detectors_by_measurement = + outputs_by_measurement(num_measurements, detector_records, "detector")?; + let observables_by_measurement = + outputs_by_measurement(num_measurements, observable_records, "observable")?; + + let mut detector_rows = Vec::with_capacity(tracked.len()); + let mut observable_rows = Vec::with_capacity(tracked.len()); + + for (ann, meta_node) in &tracked { + if dag + .gate(*meta_node) + .is_none_or(|gate| gate.gate_type != GateType::TrackedPauliMeta) + { + return Err(PauliFrameLookupError::MetaNodeNotTrackedPauliMeta { + meta_node: *meta_node, + }); + } + let start_pos = *topo_positions.get(meta_node).ok_or( + PauliFrameLookupError::MetaNodeNotTrackedPauliMeta { + meta_node: *meta_node, + }, + )?; + let affected_measurements = propagate_tracked_pauli_forward( + dag, + &topo_order, + &measurement_records, + start_pos, + &ann.pauli, + ); + detector_rows.push(measurements_to_output_row( + &affected_measurements, + &detectors_by_measurement, + )); + observable_rows.push(measurements_to_output_row( + &affected_measurements, + &observables_by_measurement, + )); + } + + Ok(Self { + num_pauli_sites: tracked.len() / 3, + num_detectors: detector_records.len(), + num_observables: observable_records.len(), + detector_rows, + observable_rows, + }) + } + + /// Number of mask sites. Each site has three tracked rows: X, Y, Z. + #[must_use] + pub fn num_pauli_sites(&self) -> usize { + self.num_pauli_sites + } + + /// Number of tracked-Pauli rows in the lookup. + #[must_use] + pub fn num_tracked_paulis(&self) -> usize { + self.detector_rows.len() + } + + /// Number of detector output columns. + #[must_use] + pub fn num_detectors(&self) -> usize { + self.num_detectors + } + + /// Number of observable output columns. + #[must_use] + pub fn num_observables(&self) -> usize { + self.num_observables + } + + /// Return the detector and observable row for one tracked-Pauli index. + #[must_use] + pub fn row_effects(&self, tracked_idx: usize) -> Option<(&[u32], &[u32])> { + self.detector_rows + .get(tracked_idx) + .zip(self.observable_rows.get(tracked_idx)) + .map(|(det, obs)| (det.as_slice(), obs.as_slice())) + } + + /// Convert a flat `(num_shots, num_pauli_sites)` mask buffer to tracked-row firings. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the lookup or when + /// any mask value is outside `0..=3`. + pub fn mask_firings( + &self, + masks: &[u8], + rows: usize, + cols: usize, + ) -> Result>, PauliFrameLookupError> { + self.validate_mask_shape(masks, rows, cols, rows)?; + let mut firings = vec![vec![false; self.num_tracked_paulis()]; rows]; + for row in 0..rows { + for col in 0..cols { + let value = masks[row * cols + col]; + if value != 0 { + firings[row][mask_value_to_tracked_idx(col, value)] = true; + } + } + } + Ok(firings) + } + + /// Compute the mask-induced XOR pattern for detectors and observables. + /// + /// Returns `(det_xor, obs_xor)` where `det_xor[i]` is the detector XOR + /// pattern for shot `i` and `obs_xor[i]` is the observable XOR pattern. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the lookup or when + /// any mask value is outside `0..=3`. + pub fn compute_mask_xor( + &self, + masks: &[u8], + rows: usize, + cols: usize, + ) -> Result { + let mut det_xor = vec![vec![false; self.num_detectors]; rows]; + let mut obs_xor = vec![vec![false; self.num_observables]; rows]; + self.apply_mask_values(masks, rows, cols, &mut det_xor, &mut obs_xor)?; + Ok((det_xor, obs_xor)) + } + + /// XOR mask-induced frame flips into sampled detector and observable rows. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the sampled batch, + /// when any mask value is outside `0..=3`, or when sampled output row widths + /// do not match the lookup dimensions. + pub fn apply_mask_values( + &self, + masks: &[u8], + rows: usize, + cols: usize, + det_events: &mut [Vec], + obs_flips: &mut [Vec], + ) -> Result<(), PauliFrameLookupError> { + self.validate_mask_shape(masks, rows, cols, det_events.len())?; + if obs_flips.len() != rows { + return Err(PauliFrameLookupError::MaskShotMismatch { + mask_rows: rows, + num_shots: obs_flips.len(), + }); + } + + for row in 0..rows { + if det_events[row].len() != self.num_detectors { + return Err(PauliFrameLookupError::OutputWidthMismatch { + kind: "detector", + row, + actual: det_events[row].len(), + expected: self.num_detectors, + }); + } + if obs_flips[row].len() != self.num_observables { + return Err(PauliFrameLookupError::OutputWidthMismatch { + kind: "observable", + row, + actual: obs_flips[row].len(), + expected: self.num_observables, + }); + } + + for col in 0..cols { + let value = masks[row * cols + col]; + if value == 0 { + continue; + } + let tracked_idx = mask_value_to_tracked_idx(col, value); + xor_row(&mut det_events[row], &self.detector_rows[tracked_idx]); + xor_row(&mut obs_flips[row], &self.observable_rows[tracked_idx]); + } + } + + Ok(()) + } + + fn validate_mask_shape( + &self, + masks: &[u8], + rows: usize, + cols: usize, + num_shots: usize, + ) -> Result<(), PauliFrameLookupError> { + let expected = rows.saturating_mul(cols); + if masks.len() != expected { + return Err(PauliFrameLookupError::MaskLengthMismatch { + len: masks.len(), + expected, + rows, + cols, + }); + } + if rows != num_shots { + return Err(PauliFrameLookupError::MaskShotMismatch { + mask_rows: rows, + num_shots, + }); + } + if cols != self.num_pauli_sites { + return Err(PauliFrameLookupError::MaskSiteMismatch { + mask_cols: cols, + num_pauli_sites: self.num_pauli_sites, + }); + } + for row in 0..rows { + for col in 0..cols { + let value = masks[row * cols + col]; + if value > 3 { + return Err(PauliFrameLookupError::InvalidMaskValue { row, col, value }); + } + } + } + Ok(()) + } +} + +fn mask_value_to_tracked_idx(site_idx: usize, value: u8) -> usize { + site_idx * 3 + usize::from(value - 1) +} + +fn xor_row(row: &mut [bool], indices: &[u32]) { + for &idx in indices { + if let Some(bit) = row.get_mut(idx as usize) { + *bit = !*bit; + } + } +} + +fn measurement_records_by_node( + dag: &DagCircuit, +) -> Result<(MeasurementRecordMap, usize), PauliFrameLookupError> { + let mut by_node = BTreeMap::new(); + let mut next_record = 0usize; + let mut num_measurements = 0usize; + + for node in dag.topological_order() { + let Some(gate) = dag.gate(node) else { + continue; + }; + if !matches!( + gate.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) { + continue; + } + if !gate.meas_ids.is_empty() && gate.meas_ids.len() != gate.qubits.len() { + return Err(PauliFrameLookupError::MalformedMeasurementIds { + node, + meas_ids: gate.meas_ids.len(), + qubits: gate.qubits.len(), + }); + } + + let mut entries = Vec::with_capacity(gate.qubits.len()); + for (idx, qubit) in gate.qubits.iter().enumerate() { + let record = if gate.meas_ids.is_empty() { + let record = next_record; + next_record += 1; + record + } else { + gate.meas_ids[idx].index() + }; + num_measurements = num_measurements.max(record + 1); + entries.push((qubit.index(), record)); + } + by_node.insert(node, entries); + } + + Ok((by_node, num_measurements.max(next_record))) +} + +fn outputs_by_measurement( + num_measurements: usize, + records_by_output: &[Vec], + kind: &'static str, +) -> Result>, PauliFrameLookupError> { + let mut outputs = vec![Vec::new(); num_measurements]; + for (output, records) in records_by_output.iter().enumerate() { + for &record in records { + let Some(measurement) = record_offset_to_absolute_index(num_measurements, record) + else { + return Err(PauliFrameLookupError::InvalidRecordOffset { + kind, + output, + record, + num_measurements, + }); + }; + if measurement >= num_measurements { + return Err(PauliFrameLookupError::InvalidRecordOffset { + kind, + output, + record, + num_measurements, + }); + } + outputs[measurement].push(output); + } + } + Ok(outputs) +} + +fn propagate_tracked_pauli_forward( + dag: &DagCircuit, + topo_order: &[usize], + measurement_records: &BTreeMap>, + start_pos: usize, + pauli: &PauliString, +) -> BTreeSet { + let mut prop = pauli_prop_from_string(pauli); + let mut affected_measurements = BTreeSet::new(); + + for &node in topo_order.iter().skip(start_pos + 1) { + let Some(gate) = dag.gate(node) else { + continue; + }; + match gate.gate_type { + GateType::TrackedPauliMeta => {} + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { + if let Some(entries) = measurement_records.get(&node) { + for &(qubit, record) in entries { + if prop.contains_x(qubit) { + affected_measurements.insert(record); + } + clear_qubit(&mut prop, qubit); + } + } + } + GateType::PZ | GateType::QAlloc => { + for qubit in &gate.qubits { + clear_qubit(&mut prop, qubit.index()); + } + } + _ => apply_gate(&mut prop, gate, Direction::Forward), + } + } + + affected_measurements +} + +fn measurements_to_output_row( + measurements: &BTreeSet, + outputs_by_measurement: &[Vec], +) -> Vec { + let mut outputs = BTreeSet::new(); + for &measurement in measurements { + if let Some(row) = outputs_by_measurement.get(measurement) { + for &output in row { + if !outputs.remove(&output) { + outputs.insert(output); + } + } + } + } + outputs + .into_iter() + .map(|idx| u32::try_from(idx).expect("detector/observable index must fit into u32")) + .collect() +} + +fn pauli_prop_from_string(pauli: &PauliString) -> PauliProp { + let mut prop = PauliProp::new(); + for (pauli, qubit) in pauli.iter_pairs() { + let qubit = qubit.index(); + match pauli { + Pauli::I => {} + Pauli::X => prop.track_x(&[qubit]), + Pauli::Z => prop.track_z(&[qubit]), + Pauli::Y => prop.track_y(&[qubit]), + } + } + prop +} + +fn clear_qubit(prop: &mut PauliProp, qubit: usize) { + if prop.contains_x(qubit) { + prop.track_x(&[qubit]); + } + if prop.contains_z(qubit) { + prop.track_z(&[qubit]); + } +} diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 99d164f04..0c9d9b85b 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -79,7 +79,9 @@ use pecos_core::{PauliString, QuarterPhase, QubitId}; use pecos_quantum::DagCircuit; use pecos_simulators::PauliProp; use smallvec::SmallVec; +use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; +use std::hash::{Hash, Hasher}; /// Reusable work buffers for propagation, avoiding per-call allocation. pub struct PropagationBuffers { @@ -113,6 +115,8 @@ pub struct FaultLocations { pub before: Vec, /// Gate type at each location. pub gate_types: Vec, + /// Idle duration at each location. 0.0 for non-idle gates. + pub idle_durations: Vec, /// Reverse index: node -> list of location IDs at that node. pub node_to_locations: Vec>, } @@ -132,6 +136,7 @@ impl FaultLocations { qubits: Vec::with_capacity(num_locations), before: Vec::with_capacity(num_locations), gate_types: Vec::with_capacity(num_locations), + idle_durations: Vec::with_capacity(num_locations), node_to_locations: vec![SmallVec::new(); max_node + 1], } } @@ -157,12 +162,14 @@ impl FaultLocations { qubits: SmallVec<[usize; 2]>, before: bool, gate_type: GateType, + idle_duration: f64, ) -> usize { let loc_id = self.nodes.len(); self.nodes.push(node); self.qubits.push(qubits); self.before.push(before); self.gate_types.push(gate_type); + self.idle_durations.push(idle_duration); // Update reverse index if node < self.node_to_locations.len() { @@ -206,7 +213,7 @@ impl FaultLocations { qubits: self.qubits[i].iter().map(|&q| QubitId::from(q)).collect(), before: self.before[i], gate_type: self.gate_types[i], - idle_duration: 0, + idle_duration: self.idle_durations[i], }) .collect() } @@ -220,7 +227,7 @@ impl FaultLocations { /// /// Unlike `SpacetimeLocation` which uses tick indices, this uses DAG node indices /// for more efficient sparse propagation. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone)] pub struct DagSpacetimeLocation { /// The node index in the DAG. pub node: usize, @@ -230,8 +237,47 @@ pub struct DagSpacetimeLocation { pub before: bool, /// The type of gate at this location. pub gate_type: GateType, - /// Duration for idle gates (in abstract time units). 0 for non-idle gates. - pub idle_duration: u64, + /// Duration for idle gates. 0.0 for non-idle gates. + pub idle_duration: f64, +} + +impl PartialEq for DagSpacetimeLocation { + fn eq(&self, other: &Self) -> bool { + self.node == other.node + && self.qubits == other.qubits + && self.before == other.before + && self.gate_type == other.gate_type + && self.idle_duration.to_bits() == other.idle_duration.to_bits() + } +} + +impl Eq for DagSpacetimeLocation {} + +impl PartialOrd for DagSpacetimeLocation { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for DagSpacetimeLocation { + fn cmp(&self, other: &Self) -> Ordering { + self.node + .cmp(&other.node) + .then_with(|| self.qubits.cmp(&other.qubits)) + .then_with(|| self.before.cmp(&other.before)) + .then_with(|| self.gate_type.cmp(&other.gate_type)) + .then_with(|| self.idle_duration.total_cmp(&other.idle_duration)) + } +} + +impl Hash for DagSpacetimeLocation { + fn hash(&self, state: &mut H) { + self.node.hash(state); + self.qubits.hash(state); + self.before.hash(state); + self.gate_type.hash(state); + self.idle_duration.to_bits().hash(state); + } } // ============================================================================ @@ -1765,6 +1811,7 @@ impl<'a> DagFaultAnalyzer<'a> { let estimated_locations = topo_order.len() * 4; let mut locations = FaultLocations::with_capacity(estimated_locations, propagator.max_node()); + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in &topo_order { if let Some(gate) = propagator.gate(node) { @@ -1785,9 +1832,28 @@ impl<'a> DagFaultAnalyzer<'a> { // Idle gates on non-active qubits provide the missing "before" // coverage that would otherwise require before-gate locations. let before = is_measurement; - for &q in &qubits { + let idle_duration = if gate.gate_type == GateType::Idle { + gate.idle_duration() + } else { + 0.0 + }; + let location_qubits: Vec = + if gate.gate_type == GateType::MeasCrosstalkGlobalPayload { + for &q in &qubits { + prepared_qubits.remove(&q); + } + let victims = prepared_qubits.iter().copied().collect(); + prepared_qubits.extend(qubits.iter().copied()); + victims + } else { + qubits.iter().copied().collect() + }; + for q in location_qubits { let single_qubit: SmallVec<[usize; 2]> = smallvec::smallvec![q]; - locations.push(node, single_qubit, before, gate.gate_type); + locations.push(node, single_qubit, before, gate.gate_type, idle_duration); + } + if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + prepared_qubits.extend(qubits.iter().copied()); } } } @@ -1840,8 +1906,11 @@ impl<'a> DagFaultAnalyzer<'a> { map.detectors.push(DetectorId::single(measurement_id)); } - // Use forest propagation: per-ancilla Phase 1/Phase 2 split. - let recorder = self.propagate_all_forest(); + // Use the generic per-measurement path for correctness with physical + // qubit reuse. The forest shortcut groups by measured qubit ID and is + // only valid when that physical qubit represents one fixed measurement + // stream, not a reusable ancilla slot. + let recorder = self.propagate_all_parallel(); // Convert buckets to SoA format (O(n) flattening) map.influences = recorder.into_soa(); @@ -2285,9 +2354,14 @@ impl<'a> DagFaultAnalyzer<'a> { } } - /// Parallel forest propagation: groups measurements by ancilla qubit, - /// propagates the latest measurement fully with capture, replays the - /// shared tail prefix for earlier measurements. + /// Parallel forest propagation for fixed ancilla streams. + /// + /// This groups measurements by physical ancilla qubit, propagates the latest + /// measurement fully with capture, and replays the shared tail prefix for + /// earlier measurements. It is an optimization for circuits where each + /// measured physical qubit represents one fixed logical measurement stream. + /// It must not be used for circuits that reuse one physical measurement + /// qubit for multiple logical checks. #[must_use] pub fn propagate_all_forest(&self) -> BucketRecorder { use rayon::prelude::*; @@ -2491,6 +2565,71 @@ mod tests { dag } + /// Reuses one physical ancilla slot for two different checks that share a data qubit. + fn reused_physical_ancilla_circuit() -> DagCircuit { + let mut dag = DagCircuit::new(); + for _ in 0..2 { + dag.qalloc(&[10]); + dag.cx(&[(0, 10)]); + dag.cx(&[(1, 10)]); + dag.mz_free(&[10]); + + dag.qalloc(&[10]); + dag.cx(&[(1, 10)]); + dag.cx(&[(2, 10)]); + dag.mz_free(&[10]); + } + dag + } + + fn build_parallel_map(analyzer: &DagFaultAnalyzer<'_>) -> DagFaultInfluenceMap { + build_map_with_influences(analyzer, analyzer.propagate_all_parallel().into_soa()) + } + + fn build_forest_map(analyzer: &DagFaultAnalyzer<'_>) -> DagFaultInfluenceMap { + build_map_with_influences(analyzer, analyzer.propagate_all_forest().into_soa()) + } + + fn build_map_with_influences( + analyzer: &DagFaultAnalyzer<'_>, + influences: InfluencesSoA, + ) -> DagFaultInfluenceMap { + let mut map = DagFaultInfluenceMap::with_capacity(analyzer.locations.len()); + map.locations = analyzer.locations.to_dag_spacetime_locations(); + + let (measurements, meas_ids) = analyzer.extract_measurements(); + map.measurements.clone_from(&measurements); + map.meas_ids = meas_ids; + + for &(node, qubit, basis) in &measurements { + map.detectors.push(DetectorId::single(MeasurementId { + tick: node, + qubit, + basis, + })); + } + + map.influences = influences; + map + } + + fn detector_fingerprint(map: &DagFaultInfluenceMap) -> Vec<(Vec, Vec)> { + vec![ + ( + map.influences.detectors_x.offsets.clone(), + map.influences.detectors_x.data.clone(), + ), + ( + map.influences.detectors_y.offsets.clone(), + map.influences.detectors_y.data.clone(), + ), + ( + map.influences.detectors_z.offsets.clone(), + map.influences.detectors_z.data.clone(), + ), + ] + } + /// Circuit with CZ gates for testing multi-qubit symmetric faults fn cz_syndrome_circuit() -> DagCircuit { let mut dag = DagCircuit::new(); @@ -2637,6 +2776,26 @@ mod tests { assert!(locations.len() >= 4); } + #[test] + fn test_build_influence_map_uses_generic_propagation_for_reused_physical_ancilla_slots() { + let dag = reused_physical_ancilla_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + + let built = analyzer.build_influence_map(); + let parallel = build_parallel_map(&analyzer); + let forest = build_forest_map(&analyzer); + + assert_eq!( + detector_fingerprint(&built), + detector_fingerprint(¶llel) + ); + assert_ne!( + detector_fingerprint(&forest), + detector_fingerprint(¶llel), + "this regression circuit should distinguish physical-slot reuse from fixed-ancilla reuse" + ); + } + #[test] fn test_dag_spacetime_location_ordering() { // Verify that DagSpacetimeLocation has consistent ordering @@ -2645,14 +2804,14 @@ mod tests { qubits: vec![QubitId::from(0)], before: true, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }; let loc2 = DagSpacetimeLocation { node: 1, qubits: vec![QubitId::from(0)], before: true, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }; assert!(loc1 < loc2); } @@ -2764,7 +2923,7 @@ mod tests { qubits: vec![QubitId(0)], before: false, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }); map.dem_output_metadata = vec![ DemOutputMetadata::tracked_pauli(pecos_core::PauliString::xs(&[0])), diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 6eebe49d0..7f994a40c 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -37,6 +37,16 @@ fn build_idle_then_measure(num_idles: usize) -> DagCircuit { dag } +fn build_nanosecond_idle_x_basis_measure() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); + dag.idle(TimeUnits::new(20), &[0]); + dag.h(&[0]); + dag.mz(&[0]); + dag +} + #[test] fn idle_locations_contribute_mechanisms_when_rates_set() { let dag = build_idle_then_measure(2); @@ -179,6 +189,88 @@ fn explicit_uniform_idle_noise_is_noisy() { ); } +#[test] +fn nanosecond_timeunit_idle_duration_is_preserved_in_fault_locations() { + let dag = build_nanosecond_idle_x_basis_measure(); + let influence = DagFaultAnalyzer::new(&dag).build_influence_map(); + + let idle = influence + .locations + .iter() + .find(|loc| loc.gate_type == GateType::Idle) + .expect("idle location"); + + assert!((idle.idle_duration - 20.0).abs() < f64::EPSILON); +} + +#[test] +fn linear_memory_z_noise_uses_idle_duration_in_dem() { + let dag = build_nanosecond_idle_x_basis_measure(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let dem = DemBuilder::new(&influence) + .with_noise_config(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear_rate(1.0e-3)) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + assert!( + dem.num_contributions() > 0, + "linear Z-memory noise on an idle should produce DEM contributions", + ); +} + +// px and py must be *exactly* zero for these Z-only memory models: the X/Y idle +// rates are 0, so the composed channel introduces no X/Y probability. An epsilon +// check would weaken that invariant, so compare against the exact 0.0 constant. +#[allow(clippy::float_cmp)] +#[test] +fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { + let linear = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_linear_rate(1.0e-3) + .idle_pauli_probs(20.0); + assert_eq!(linear.px, 0.0); + assert_eq!(linear.py, 0.0); + assert!((linear.pz - 0.02).abs() < 1e-15); + + let quadratic = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_quadratic_rate(0.1) + .idle_pauli_probs(2.0); + assert_eq!(quadratic.px, 0.0); + assert_eq!(quadratic.py, 0.0); + assert!((quadratic.pz - 0.4).abs() < 1e-15); + + let pauli = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_pauli_linear_rates(1.0e-3, 2.0e-3, 3.0e-3) + .set_idle_pauli_quadratic_rates(1.0e-4, 2.0e-4, 3.0e-4) + .idle_memory_pauli_probs(10.0); + assert!((pauli.px - 0.02).abs() < 1e-15); + assert!((pauli.py - 0.04).abs() < 1e-15); + assert!((pauli.pz - 0.06).abs() < 1e-15); +} + +// px and py must be *exactly* zero for this Z-only sine model: the X/Y idle rates +// are 0, so no X/Y probability is introduced. An epsilon check would weaken that +// invariant, so compare against the exact 0.0 constant. +#[allow(clippy::float_cmp)] +#[test] +fn idle_memory_pauli_probabilities_support_quadratic_sine_model() { + let z_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_quadratic_sine_rate(0.2) + .idle_memory_pauli_probs(3.0); + assert_eq!(z_sine.px, 0.0); + assert_eq!(z_sine.py, 0.0); + assert!((z_sine.pz - 0.6_f64.sin().powi(2)).abs() < 1e-15); + + let pauli_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_pauli_quadratic_sine_rates(0.1, 0.2, 0.3) + .idle_memory_pauli_probs(2.0); + assert!((pauli_sine.px - 0.2_f64.sin().powi(2)).abs() < 1e-15); + assert!((pauli_sine.py - 0.4_f64.sin().powi(2)).abs() < 1e-15); + assert!((pauli_sine.pz - 0.6_f64.sin().powi(2)).abs() < 1e-15); +} + #[test] fn dem_builder_scalar_p1_does_not_attach_to_idle() { let dag = build_idle_then_measure(1); diff --git a/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs b/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs new file mode 100644 index 000000000..c2da2ce5c --- /dev/null +++ b/crates/pecos-qec/tests/per_gate_neo_mapping_tests.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. + +//! Differential tests for `PerGateTypeNoise::to_neo_channel`. +//! +//! Each test configures noise through the pecos-qec type (qec orderings +//! and conventions), converts, runs circuit-level Monte Carlo on the neo +//! stack, and checks the measured rates against the analytic values the +//! qec configuration implies. The two-qubit cells are chosen to fail +//! loudly if the `PAULI_2Q_ORDER` -> `TWO_QUBIT_PAULIS` permutation or +//! the qubit-pair orientation ever drifts. + +#![cfg(feature = "neo")] + +use pecos_core::QubitId; +use pecos_core::gate_type::GateType; +use pecos_neo::noise::PerGatePauliChannel; +use pecos_neo::prelude::*; +use pecos_qec::fault_tolerance::dem_builder::{NoiseConfig, PerGateTypeNoise}; +use pecos_simulators::SparseStab; + +const SHOTS: usize = 20_000; + +/// Rate of outcome-1 on one qubit over `SHOTS` runs of a circuit. +#[allow(clippy::cast_precision_loss)] +fn one_rate(noise: &PerGateTypeNoise, commands: &CommandQueue, qubit: usize) -> f64 { + let model = ComposableNoiseModel::new().add_channel(noise.to_neo_channel()); + let mut state = SparseStab::new(2); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let qubits = [QubitId(qubit)]; + let mut ones = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, commands).unwrap(); + if let Some(bits) = outcomes.bitstring(&qubits) + && bits[0] + { + ones += 1; + } + } + ones as f64 / SHOTS as f64 +} + +#[allow(clippy::cast_precision_loss)] +fn five_sigma(p: f64) -> f64 { + 5.0 * (p * (1.0 - p) / SHOTS as f64).sqrt() +} + +/// Index of a Pauli-pair label in qec's `PAULI_2Q_ORDER`. +fn qec_2q_index(label: &str) -> usize { + pecos_qec::fault_tolerance::dem_builder::PAULI_2Q_ORDER + .iter() + .position(|&entry| entry == label) + .expect("valid Pauli pair label") +} + +#[test] +fn per_gate_1q_rates_map_with_analytic_flip_rate() { + // 30% X-error on X gates (qec [X, Y, Z] ordering): the injected X + // cancels the gate, so P(outcome = 0) = 0.3. + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_1q_rates(GateType::X, [0.3, 0.0, 0.0]); + + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let rate = 1.0 - one_rate(&noise, &commands, 0); + assert!( + (rate - 0.3).abs() < five_sigma(0.3), + "1q gate rate: got flip rate {rate}, expected 0.3" + ); +} + +#[test] +fn per_qubit_1q_rates_override_per_gate() { + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_1q_rates(GateType::X, [0.3, 0.0, 0.0]) + .with_1q_rates_for_qubit(GateType::X, QubitId(0), [0.0; 3]); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .x(&[0]) + .x(&[1]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let flip0 = 1.0 - one_rate(&noise, &commands, 0); + let flip1 = 1.0 - one_rate(&noise, &commands, 1); + assert!( + flip0.abs() < f64::EPSILON, + "qubit 0 override must be noiseless, got {flip0}" + ); + assert!( + (flip1 - 0.3).abs() < five_sigma(0.3), + "qubit 1 keeps the per-gate rate: got {flip1}, expected 0.3" + ); +} + +#[test] +fn qec_2q_ordering_is_permuted_correctly() { + // Configure, IN QEC ORDERING, a 25% "IX" error on CX: identity on the + // first (control) qubit, X on the second (target). If the permutation + // into neo's ordering drifted, the error would land on the wrong + // Pauli pair and the wrong qubit would flip. + let mut rates = [0.0; 15]; + rates[qec_2q_index("IX")] = 0.25; + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, rates); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .cx(&[(0, 1)]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = one_rate(&noise, &commands, 0); + let rate1 = one_rate(&noise, &commands, 1); + assert!( + rate0.abs() < f64::EPSILON, + "control qubit must be untouched by IX, got {rate0}" + ); + assert!( + (rate1 - 0.25).abs() < five_sigma(0.25), + "target qubit must flip at the IX rate, got {rate1}" + ); +} + +#[test] +fn qec_2q_per_pair_rates_override_per_gate() { + // Per-gate: 20% "XI" (control flips). Per-pair override for (0, 1): + // 20% "IX" (target flips). The override must win for that pair. + let mut gate_rates = [0.0; 15]; + gate_rates[qec_2q_index("XI")] = 0.2; + let mut pair_rates = [0.0; 15]; + pair_rates[qec_2q_index("IX")] = 0.2; + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, gate_rates) + .with_2q_rates_for_qubits(GateType::CX, QubitId(0), QubitId(1), pair_rates); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .cx(&[(0, 1)]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = one_rate(&noise, &commands, 0); + let rate1 = one_rate(&noise, &commands, 1); + assert!( + rate0.abs() < f64::EPSILON, + "pair override replaces the per-gate XI error, got control rate {rate0}" + ); + assert!( + (rate1 - 0.2).abs() < five_sigma(0.2), + "pair override applies IX to the target: got {rate1}, expected 0.2" + ); +} + +#[test] +fn measurement_and_init_rates_map_with_per_qubit_overrides() { + // p_meas/p_init are seeded from the base config's p_meas/p_prep. + let base = NoiseConfig { + p_meas: 0.1, + ..NoiseConfig::uniform(0.0) + }; + let noise = PerGateTypeNoise::from_base_noise(base).with_measurement_rate(QubitId(1), 0.4); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = one_rate(&noise, &commands, 0); + let rate1 = one_rate(&noise, &commands, 1); + assert!( + (rate0 - 0.1).abs() < five_sigma(0.1), + "default meas rate: got {rate0}, expected 0.1" + ); + assert!( + (rate1 - 0.4).abs() < five_sigma(0.4), + "per-qubit meas rate: got {rate1}, expected 0.4" + ); +} + +#[test] +fn base_noise_back_fills_unlisted_gates() { + // base p1 = 0.3 -> uniform per-Pauli 0.1; X and Y flip the outcome + // after an X gate: P(outcome = 1) = 0.8. + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig { + p1: 0.3, + ..NoiseConfig::uniform(0.0) + }); + + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let rate = one_rate(&noise, &commands, 0); + assert!( + (rate - 0.8).abs() < five_sigma(0.8), + "base fallback: got outcome-1 rate {rate}, expected 0.8" + ); +} + +#[test] +#[should_panic(expected = "cannot carry idle noise")] +fn idle_configuration_is_rejected_not_dropped() { + // NoiseConfig::uniform sets p_idle = p, and the DEM built from this + // config includes idle contributions; silently dropping them in the + // conversion would change the physics. + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig { + p_idle: 0.001, + ..NoiseConfig::uniform(0.0) + }); + let _ = noise.to_neo_channel(); +} + +#[test] +#[should_panic(expected = "cannot carry idle noise")] +fn idle_gate_entries_are_rejected_not_dropped() { + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_1q_rates(GateType::Idle, [0.001, 0.0, 0.0]); + let _ = noise.to_neo_channel(); +} + +#[test] +#[allow(clippy::cast_precision_loss)] +fn default_noise_config_carries_realistic_base_rates_without_idle_panic() { + // The realistic-nonzero-defaults trap: NoiseConfig::default() is 0.01 + // EVERYWHERE (p1/p2/p_meas/p_prep), not off — it bit the GNM and qec + // mappings before. Lock that to_neo_channel on the default config + // (a) does NOT trip the idle guard (default has p_idle = 0, t1/t2 = None, + // so this call would panic if it did) and (b) carries the 0.01 + // base/meas/init rates EXACTLY — bit-identical to the neo channel built + // by hand with those values (a mishandling that dropped or rescaled the + // defaults would diverge). + let from_default = PerGateTypeNoise::from_base_noise(NoiseConfig::default()).to_neo_channel(); + let by_hand = PerGatePauliChannel::new() + .with_base(0.01, 0.01) + .with_meas_init(0.01, 0.01); + + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let count_ones = |channel: PerGatePauliChannel| -> usize { + let model = ComposableNoiseModel::new().add_channel(channel); + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let qubits = [QubitId(0)]; + let mut ones = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); + if let Some(bits) = outcomes.bitstring(&qubits) + && bits[0] + { + ones += 1; + } + } + ones + }; + + let default_ones = count_ones(from_default); + let hand_ones = count_ones(by_hand); + + assert_eq!( + default_ones, hand_ones, + "to_neo_channel(NoiseConfig::default()) must carry the 0.01 base/meas/init rates \ + exactly (got {default_ones} vs hand-built {hand_ones})" + ); + // The defaults are NOT silently dropped: the circuit's nominal outcome + // is 1 (X flips |0> to |1>), so the error rate is the fraction reading + // 0 — a small but nonzero value from the combined 0.01 prep/gate/meas + // sources (~0.027), confirming the defaults carry rather than vanish. + let error_rate = 1.0 - default_ones as f64 / SHOTS as f64; + assert!( + error_rate > 0.0 && error_rate < 0.1, + "the realistic 0.01 defaults must produce a small nonzero error rate, got {error_rate}" + ); +} diff --git a/crates/pecos-qis-ffi-types/src/lib.rs b/crates/pecos-qis-ffi-types/src/lib.rs index 31a0f2f84..26da989e2 100644 --- a/crates/pecos-qis-ffi-types/src/lib.rs +++ b/crates/pecos-qis-ffi-types/src/lib.rs @@ -7,7 +7,7 @@ mod operations; -pub use operations::{Operation, QuantumOp}; +pub use operations::{LoweredQuantumOp, NamedResultTrace, Operation, QuantumOp, TraceMetadata}; const DEFAULT_OPERATION_CAPACITY: usize = 1024; const DEFAULT_MEASUREMENT_CAPACITY: usize = 256; diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 8374db844..7cfc5f604 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -3,12 +3,44 @@ //! This module defines the quantum operations that can be collected by the interface //! and later executed by a runtime. +use std::collections::BTreeMap; + +/// Structured metadata attached to QIS operations or lowered quantum operations. +pub type TraceMetadata = BTreeMap; + +/// Runtime provenance for a named `result(...)` output. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NamedResultTrace { + /// Name passed to `result(name, value)`. + pub name: String, + /// Boolean values emitted for this result call. + pub values: Vec, + /// Runtime measurement result IDs read to produce `values`, in element order. + pub result_ids: Vec, +} + /// High-level quantum operations that include both QIS and control flow #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum Operation { /// Quantum gate operation Quantum(QuantumOp), + /// Source-level metadata intended to annotate subsequent lowered operations. + /// + /// Runtimes may preserve this metadata when lowering, scheduling, or expanding + /// operations. PECOS's direct lowering path attaches it to the next emitted + /// simulator gate and then clears it. + TraceMetadata { + metadata: TraceMetadata, + /// Optional source qubit that owns this metadata. + /// + /// Runtime lowering uses this to wait for the next compatible source + /// operation touching the same qubit, which is stricter than attaching + /// metadata to the next operation in global program order. + #[serde(default)] + qubit: Option, + }, + /// Allocate a qubit AllocateQubit { id: usize }, @@ -49,6 +81,9 @@ pub enum QuantumOp { // Hardware-native gates (for Selene compatibility) RXY(f64, f64, usize), // theta, phi, qubit + // Idle period in seconds for time-based noise models + Idle(f64, usize), // duration_seconds, qubit + // Two-qubit gates CX(usize, usize), CY(usize, usize), @@ -72,6 +107,32 @@ pub enum QuantumOp { Reset(usize), } +/// A lowered quantum operation plus any provenance supplied by the lowering runtime. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct LoweredQuantumOp { + /// Lowered operation to send to the quantum/noise engine. + pub op: QuantumOp, + /// Generic trace/source metadata associated with this lowered operation. + pub metadata: TraceMetadata, +} + +impl LoweredQuantumOp { + /// Create a lowered operation with explicit metadata. + #[must_use] + pub fn new(op: QuantumOp, metadata: TraceMetadata) -> Self { + Self { op, metadata } + } +} + +impl From for LoweredQuantumOp { + fn from(op: QuantumOp) -> Self { + Self { + op, + metadata: TraceMetadata::new(), + } + } +} + impl From for Operation { fn from(op: QuantumOp) -> Self { Operation::Quantum(op) diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 9a2f3bfd4..f40c65ac1 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -4,10 +4,18 @@ //! with Rust. These functions simply collect operations into the thread-local interface //! without performing any simulation or complex state management. -use crate::{Operation, QuantumOp, with_interface}; +use crate::{Operation, QuantumOp, TraceMetadata, with_interface}; use log::debug; use std::cell::Cell; +/// C ABI return value for helpers that consume and return two qubits. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QubitPair { + pub first: i64, + pub second: i64, +} + // Thread-local counter to prevent infinite loops in collection mode. // After MAX_COLLECTION_READS, `___read_future_bool` returns true to break out of // loops like "repeat_until_one" (while not result: ... result = measure(q)). @@ -26,6 +34,59 @@ fn i64_to_usize(value: i64) -> usize { usize::try_from(value).expect("Invalid ID: value must be non-negative and fit in usize") } +const PACKED_TRACE_METADATA_JSON_KEY: &str = "__pecos_trace_metadata_json_v1__"; + +unsafe fn read_tket_string_arg( + func_name: &str, + arg_name: &str, + ptr: *const u8, + len: i64, +) -> Option { + let Ok(len) = usize::try_from(len) else { + log::error!("{func_name}: invalid {arg_name} length {len}"); + return None; + }; + if ptr.is_null() { + log::error!("{func_name}: null {arg_name} pointer"); + return None; + } + + // The tket2 string format is: {len: u8, data: [u8; len]}. + // The pointer references the length byte, so skip it to read the payload. + let data_ptr = unsafe { ptr.add(1) }; + let bytes = unsafe { std::slice::from_raw_parts(data_ptr, len) }; + if let Ok(value) = std::str::from_utf8(bytes) { + Some(value.to_string()) + } else { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None + } +} + +unsafe fn read_direct_string_arg( + func_name: &str, + arg_name: &str, + ptr: *const u8, + len: i64, +) -> Option { + let Ok(len) = usize::try_from(len) else { + log::error!("{func_name}: invalid {arg_name} length {len}"); + return None; + }; + if ptr.is_null() { + log::error!("{func_name}: null {arg_name} pointer"); + return None; + } + + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; + if let Ok(value) = std::str::from_utf8(bytes) { + Some(value.to_string()) + } else { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None + } +} + // --- Gate FFI Macros --- // // These macros generate the boilerplate for FFI gate functions. @@ -266,6 +327,13 @@ pub unsafe extern "C" fn __quantum__rt__result_allocate() -> i64 { // --- Result Retrieval --- +fn record_result_read(result_id: usize) { + if let Some(ctx) = crate::get_execution_context() { + // SAFETY: Context is valid for duration of execution. + unsafe { &*ctx }.record_result_read(result_id); + } +} + /// Get measurement result (returns 1 if result is One, 0 otherwise) /// /// This function supports dynamic circuits: if the result is not yet available and @@ -284,6 +352,7 @@ pub unsafe extern "C" fn __quantum__rt__result_get_one(result: i64) -> i32 { let existing_result = with_interface(|interface| interface.get_result(result_id)); if let Some(value) = existing_result { + record_result_read(result_id); return i32::from(value); } @@ -300,7 +369,10 @@ pub unsafe extern "C" fn __quantum__rt__result_get_one(result: i64) -> i32 { ); 0 }, - i32::from, + |value| { + record_result_read(result_id); + i32::from(value) + }, ) }) } else { @@ -342,6 +414,233 @@ pub unsafe extern "C" fn __quantum__rt__record(data: *const std::ffi::c_char) { } } +fn trace_metadata_from_key_value( + func_name: &str, + key: String, + value: String, +) -> Option { + if key != PACKED_TRACE_METADATA_JSON_KEY { + let mut metadata = TraceMetadata::new(); + metadata.insert(key, value); + return Some(metadata); + } + + match serde_json::from_str::(&value) { + Ok(metadata) => Some(metadata), + Err(err) => { + log::error!("{func_name}: invalid packed trace metadata JSON: {err}"); + None + } + } +} + +fn queue_trace_metadata(func_name: &str, key: String, value: String, qubit: Option) { + let Some(metadata) = trace_metadata_from_key_value(func_name, key, value) else { + return; + }; + with_interface(|interface| { + interface.queue_operation(Operation::TraceMetadata { metadata, qubit }); + }); +} + +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This function uses the tket2 string ABI: each string pointer references a +/// `{len: u8, data: [u8; len]}` payload and the length argument gives the data +/// length. Metadata is intentionally represented as ordinary key/value strings +/// so callers can add generic provenance without PECOS knowing about a specific +/// runtime or hardware target. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs with at least +/// `len + 1` bytes. Invalid pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata( + key_ptr: *const u8, + key_len: i64, + value_ptr: *const u8, + value_len: i64, +) { + let Some(key) = + (unsafe { read_tket_string_arg("pecos_qis_trace_metadata", "key", key_ptr, key_len) }) + else { + return; + }; + let Some(value) = (unsafe { + read_tket_string_arg("pecos_qis_trace_metadata", "value", value_ptr, value_len) + }) else { + return; + }; + queue_trace_metadata("pecos_qis_trace_metadata", key, value, None); +} + +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This variant matches the HUGR lowering ABI for Guppy string arguments: each +/// argument is passed as a pointer to a tket2 string payload whose first byte is +/// the string length. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs. Invalid +/// pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_hugr(key_ptr: *const u8, value_ptr: *const u8) { + if key_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_hugr: null key pointer"); + return; + } + if value_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_hugr: null value pointer"); + return; + } + + let key_len = i64::from(unsafe { *key_ptr }); + let value_len = i64::from(unsafe { *value_ptr }); + let Some(key) = + (unsafe { read_tket_string_arg("pecos_qis_trace_metadata_hugr", "key", key_ptr, key_len) }) + else { + return; + }; + let Some(value) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_hugr", + "value", + value_ptr, + value_len, + ) + }) else { + return; + }; + queue_trace_metadata("pecos_qis_trace_metadata_hugr", key, value, None); +} + +/// Attach source/runtime metadata to the next operation on a specific qubit. +/// +/// Returning the qubit handle gives Guppy/HUGR a data dependency that preserves +/// the metadata call immediately before the gate it annotates. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs. Invalid +/// pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_qubit_hugr( + qubit: i64, + key_ptr: *const u8, + value_ptr: *const u8, +) -> i64 { + if key_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_qubit_hugr: null key pointer"); + return qubit; + } + if value_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_qubit_hugr: null value pointer"); + return qubit; + } + + let key_len = i64::from(unsafe { *key_ptr }); + let value_len = i64::from(unsafe { *value_ptr }); + let Some(key) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_qubit_hugr", + "key", + key_ptr, + key_len, + ) + }) else { + return qubit; + }; + let Some(value) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_qubit_hugr", + "value", + value_ptr, + value_len, + ) + }) else { + return qubit; + }; + queue_trace_metadata( + "pecos_qis_trace_metadata_qubit_hugr", + key, + value, + Some(i64_to_usize(qubit)), + ); + qubit +} + +/// Insert a runtime scheduling barrier after prior operations touching this qubit. +/// +/// Returning the qubit handle gives Guppy/HUGR a data dependency that keeps the +/// barrier between the preceding operation on this qubit and the following +/// operation that consumes the returned handle. The barrier itself is a +/// runtime-level batch/drain marker; it does not emit a quantum gate. +/// +/// # Safety +/// Called from C/LLVM code. Qubit must be a valid non-negative ID. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_runtime_barrier_qubit_hugr(qubit: i64) -> i64 { + let _ = i64_to_usize(qubit); + with_interface(|interface| { + interface.queue_operation(Operation::Barrier); + }); + qubit +} + +/// Insert a runtime scheduling barrier after prior operations touching either qubit. +/// +/// The returned qubit pair gives Guppy/HUGR data dependencies on both inputs. A +/// caller can place this helper immediately before a hosted local pulse so that +/// the local pulse cannot be scheduled before the host qubit is ready. +/// +/// # Safety +/// Called from C/LLVM code. Qubits must be valid non-negative IDs. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_runtime_barrier_qubits2_hugr( + first: i64, + second: i64, +) -> QubitPair { + let _ = i64_to_usize(first); + let _ = i64_to_usize(second); + with_interface(|interface| { + interface.queue_operation(Operation::Barrier); + }); + QubitPair { first, second } +} + +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This variant uses direct string data pointers instead of the tket2 string +/// struct layout. It is useful for runtime shims that already carry plain +/// pointer/length pairs. +/// +/// # Safety +/// The key and value pointers must reference valid UTF-8 data of the provided +/// lengths. Invalid pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_direct( + key_ptr: *const u8, + key_len: i64, + value_ptr: *const u8, + value_len: i64, +) { + let Some(key) = (unsafe { + read_direct_string_arg("pecos_qis_trace_metadata_direct", "key", key_ptr, key_len) + }) else { + return; + }; + let Some(value) = (unsafe { + read_direct_string_arg( + "pecos_qis_trace_metadata_direct", + "value", + value_ptr, + value_len, + ) + }) else { + return; + }; + queue_trace_metadata("pecos_qis_trace_metadata_direct", key, value, None); +} + // --- Selene-style FFI Functions --- // // These functions match the naming convention used by Selene's hugr-qis compiler. @@ -473,6 +772,7 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { log::debug!("___read_future_bool: existing_result={existing_result:?}"); if let Some(result) = existing_result { + record_result_read(result_id); return result; } @@ -484,6 +784,7 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { log::debug!( "___read_future_bool: result already in context for result_id={result_id}: {result}" ); + record_result_read(result_id); return result; } @@ -498,6 +799,9 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { // The main thread stores results there to cross the thread boundary let result = crate::get_measurement_result(result_id as u64); log::debug!("___read_future_bool: got result after waiting: {result:?}"); + if result.is_some() { + record_result_read(result_id); + } return result.unwrap_or(false); } log::debug!("___read_future_bool: timeout waiting for result"); @@ -1293,6 +1597,177 @@ mod tests { }); } + #[test] + fn test_trace_metadata_direct() { + setup_test(); + let key = b"source_label"; + let value = b"szz_prefix:H:data_0"; + unsafe { + pecos_qis_trace_metadata_direct( + key.as_ptr(), + i64::try_from(key.len()).expect("test key length fits in i64"), + value.as_ptr(), + i64::try_from(value.len()).expect("test value length fits in i64"), + ); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, None); + assert_eq!( + metadata.get("source_label").map(String::as_str), + Some("szz_prefix:H:data_0") + ); + }); + } + + #[test] + fn test_trace_metadata_tket_string_layout() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + unsafe { + pecos_qis_trace_metadata(key.as_ptr(), 11, value.as_ptr(), 10); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, None); + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + + #[test] + fn test_trace_metadata_hugr_string_layout() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + unsafe { + pecos_qis_trace_metadata_hugr(key.as_ptr(), value.as_ptr()); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, None); + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + + #[test] + fn test_trace_metadata_qubit_hugr_returns_qubit_and_queues_metadata() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + let returned = + unsafe { pecos_qis_trace_metadata_qubit_hugr(17, key.as_ptr(), value.as_ptr()) }; + assert_eq!(returned, 17); + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, Some(17)); + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + + #[test] + fn test_trace_metadata_qubit_hugr_expands_packed_json_metadata() { + setup_test(); + let mut key = Vec::with_capacity(PACKED_TRACE_METADATA_JSON_KEY.len() + 1); + // tket "pascal string" layout: a single-byte length prefix, so the key + // length must fit in u8 (< 256). + key.push( + u8::try_from(PACKED_TRACE_METADATA_JSON_KEY.len()).expect("key length fits in u8"), + ); + key.extend_from_slice(PACKED_TRACE_METADATA_JSON_KEY.as_bytes()); + let value = br#"{"host_id":"probe:host","source_kind":"szz_host"}"#; + let mut packed = Vec::with_capacity(value.len() + 1); + // Single-byte length prefix (tket pascal-string layout): value length < 256. + packed.push(u8::try_from(value.len()).expect("value length fits in u8")); + packed.extend_from_slice(value); + + let returned = + unsafe { pecos_qis_trace_metadata_qubit_hugr(19, key.as_ptr(), packed.as_ptr()) }; + assert_eq!(returned, 19); + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, Some(19)); + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_host") + ); + assert_eq!( + metadata.get("host_id").map(String::as_str), + Some("probe:host") + ); + assert!(!metadata.contains_key(PACKED_TRACE_METADATA_JSON_KEY)); + }); + } + + #[test] + fn test_runtime_barrier_qubit_hugr_returns_qubit_and_queues_barrier() { + setup_test(); + let returned = unsafe { pecos_qis_runtime_barrier_qubit_hugr(17) }; + assert_eq!(returned, 17); + + with_interface(|iface| { + assert_eq!(iface.operations, vec![Operation::Barrier]); + }); + } + + #[test] + fn test_runtime_barrier_qubits2_hugr_returns_qubits_and_queues_barrier() { + setup_test(); + let returned = unsafe { pecos_qis_runtime_barrier_qubits2_hugr(17, 23) }; + assert_eq!( + returned, + QubitPair { + first: 17, + second: 23, + }, + ); + + with_interface(|iface| { + assert_eq!(iface.operations, vec![Operation::Barrier]); + }); + } + // --- Measurement and reset tests --- #[test] @@ -1505,6 +1980,26 @@ mod tests { assert!(result); } + #[test] + fn test_named_result_trace_consumes_recorded_result_reads() { + let ctx = crate::ExecutionContext::new(); + + ctx.record_result_read(7); + ctx.store_named_bool("m", true); + ctx.record_result_read(8); + ctx.record_result_read(9); + ctx.store_named_array("arr", &[false, true]); + + let traces = ctx.get_named_result_traces(); + assert_eq!(traces.len(), 2); + assert_eq!(traces[0].name, "m"); + assert_eq!(traces[0].values, vec![true]); + assert_eq!(traces[0].result_ids, vec![7]); + assert_eq!(traces[1].name, "arr"); + assert_eq!(traces[1].values, vec![false, true]); + assert_eq!(traces[1].result_ids, vec![8, 9]); + } + #[test] fn test_read_future_bool_default() { setup_test(); diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index b772a8749..3e3005a6e 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -66,6 +66,10 @@ pub struct ExecutionContext { pub measurement_results: Mutex>>, /// Storage for named results from `print_bool`/`print_bool_arr` (e.g., "synx", "final") pub named_results: Mutex>>, + /// Runtime provenance for each `result(...)` output call. + pub named_result_traces: Mutex>, + /// Result IDs read since the last named output consumed them. + pub pending_result_reads: Mutex>, } impl ExecutionContext { @@ -80,6 +84,8 @@ impl ExecutionContext { pending_ops: Mutex::new(Vec::new()), measurement_results: Mutex::new(Vec::new()), named_results: Mutex::new(BTreeMap::new()), + named_result_traces: Mutex::new(Vec::new()), + pending_result_reads: Mutex::new(Vec::new()), } } @@ -101,6 +107,53 @@ impl ExecutionContext { if let Ok(mut named) = self.named_results.lock() { named.clear(); } + if let Ok(mut traces) = self.named_result_traces.lock() { + traces.clear(); + } + if let Ok(mut reads) = self.pending_result_reads.lock() { + reads.clear(); + } + } + + /// Record that program execution read a runtime measurement result. + pub fn record_result_read(&self, result_id: usize) { + if let Ok(mut reads) = self.pending_result_reads.lock() { + reads.push(result_id); + } else { + log::error!("ExecutionContext::record_result_read failed to acquire lock"); + } + } + + fn take_result_reads(&self, count: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + let Ok(mut reads) = self.pending_result_reads.lock() else { + log::error!("ExecutionContext::take_result_reads failed to acquire lock"); + return Vec::new(); + }; + if reads.len() < count { + log::warn!( + "Named result output expected {count} result read(s), but only {} were recorded", + reads.len() + ); + return Vec::new(); + } + reads.drain(..count).collect() + } + + fn store_named_result_trace(&self, name: &str, values: &[bool], result_ids: Vec) { + if let Ok(mut traces) = self.named_result_traces.lock() { + traces.push(NamedResultTrace { + name: name.to_string(), + values: values.to_vec(), + result_ids, + }); + } else { + log::error!( + "ExecutionContext::store_named_result_trace failed to acquire lock for '{name}'" + ); + } } /// Store a named result (single bool value) @@ -122,6 +175,8 @@ impl ExecutionContext { "ExecutionContext::store_named_bool: thread {thread_id:?} failed to acquire lock for '{name}'" ); } + let result_ids = self.take_result_reads(1); + self.store_named_result_trace(name, &[value], result_ids); } /// Store a named result array (multiple bool values) @@ -130,6 +185,8 @@ impl ExecutionContext { let entry = named.entry(name.to_string()).or_default(); entry.extend_from_slice(values); } + let result_ids = self.take_result_reads(values.len()); + self.store_named_result_trace(name, values, result_ids); } /// Get all named results (returns a clone) @@ -140,6 +197,15 @@ impl ExecutionContext { .map(|guard| guard.clone()) .unwrap_or_default() } + + /// Get all named result provenance records (returns a clone) + #[must_use] + pub fn get_named_result_traces(&self) -> Vec { + self.named_result_traces + .lock() + .map(|guard| guard.clone()) + .unwrap_or_default() + } } impl Default for ExecutionContext { @@ -201,7 +267,9 @@ fn get_execution_context() -> Option<*mut ExecutionContext> { } // Re-export all types from pecos-qis-ffi-types -pub use pecos_qis_ffi_types::{Operation, OperationCollector, OperationList, QuantumOp}; +pub use pecos_qis_ffi_types::{ + NamedResultTrace, Operation, OperationCollector, OperationList, QuantumOp, TraceMetadata, +}; /// Type alias for the quantum executor callback /// @@ -538,18 +606,24 @@ pub fn wait_for_result_ready(result_id: u64, timeout_ms: u64) -> bool { } ctx.sync_condvar.notify_all(); - // Wait for result to be ready + // Wait for result to be ready. Condition variables may wake spuriously, so + // keep waiting until the predicate changes or the timeout expires. let timeout = Duration::from_millis(timeout_ms); - let Ok(mut state) = ctx.sync_state.lock() else { + let Ok(state) = ctx.sync_state.lock() else { return false; }; - if !state.result_ready { - let result = ctx.sync_condvar.wait_timeout(state, timeout); - state = match result { - Ok((s, _)) => s, - Err(_) => return false, - }; + let result = ctx + .sync_condvar + .wait_timeout_while(state, timeout, |state| { + !state.result_ready && !state.worker_complete + }); + let Ok((state, timed_out)) = result else { + return false; + }; + + if timed_out.timed_out() && !state.result_ready { + log::debug!("wait_for_result_ready: timeout"); } log::debug!("wait_for_result_ready: result_ready={}", state.result_ready); @@ -745,10 +819,49 @@ pub extern "C" fn pecos_get_named_results_json() -> *mut std::ffi::c_char { } } +/// Get named result runtime provenance from execution context as JSON. +/// +/// Returns a pointer to a heap-allocated null-terminated JSON string containing +/// records of `result(...)` calls with the measurement result IDs used to +/// produce each output value. +/// +/// The caller must free the returned string using `pecos_free_named_results_json`. +/// Returns null if no context is registered or traces are empty. +#[unsafe(no_mangle)] +pub extern "C" fn pecos_get_named_result_traces_json() -> *mut std::ffi::c_char { + let Some(ctx) = get_execution_context() else { + return std::ptr::null_mut(); + }; + + // SAFETY: Context is valid for duration of execution + let ctx = unsafe { &*ctx }; + let traces = ctx.get_named_result_traces(); + if traces.is_empty() { + return std::ptr::null_mut(); + } + + let json = match serde_json::to_string(&traces) { + Ok(s) => s, + Err(e) => { + log::error!("pecos_get_named_result_traces_json: serialization error: {e}"); + return std::ptr::null_mut(); + } + }; + + match std::ffi::CString::new(json) { + Ok(cstr) => cstr.into_raw(), + Err(e) => { + log::error!("pecos_get_named_result_traces_json: CString error: {e}"); + std::ptr::null_mut() + } + } +} + /// Free a JSON string allocated by `pecos_get_named_results_json` /// /// # Safety -/// The pointer must have been allocated by `pecos_get_named_results_json`. +/// The pointer must have been allocated by `pecos_get_named_results_json` or +/// `pecos_get_named_result_traces_json`. #[unsafe(no_mangle)] pub unsafe extern "C" fn pecos_free_named_results_json(ptr: *mut std::ffi::c_char) { if !ptr.is_null() { @@ -763,6 +876,8 @@ mod tests { use std::sync::Arc; use std::thread; + const TEST_SYNC_TIMEOUT_MS: u64 = 5_000; + /// Helper to create and register an execution context for tests fn setup_context() -> *mut ExecutionContext { let ctx = pecos_create_execution_context(); @@ -1014,7 +1129,7 @@ mod tests { barrier.wait(); // Now worker has definitely set need_result - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 5); // Provide the result @@ -1224,7 +1339,7 @@ mod tests { worker_barrier.wait(); // Wait for main thread to signal it needs a result - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 5); pecos_signal_result_ready(); @@ -1234,7 +1349,7 @@ mod tests { barrier.wait(); // Wait for result - this should export operations to context storage - let result = wait_for_result_ready(5, 500); + let result = wait_for_result_ready(5, TEST_SYNC_TIMEOUT_MS); assert!(result); // Verify operations were exported to context storage @@ -1272,7 +1387,7 @@ mod tests { worker_barrier.wait(); - let result = wait_for_result_ready(42, 500); + let result = wait_for_result_ready(42, TEST_SYNC_TIMEOUT_MS); unsafe { pecos_register_execution_context(std::ptr::null_mut()) }; result @@ -1281,7 +1396,7 @@ mod tests { barrier.wait(); // Main thread: wait for worker to signal it needs result - let needed = pecos_wait_for_need_result(500); + let needed = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed, 42); // Signal result ready @@ -1323,7 +1438,7 @@ mod tests { worker_barrier.wait(); // This will export ops and wait for result - let result = if wait_for_result_ready(0, 500) { + let result = if wait_for_result_ready(0, TEST_SYNC_TIMEOUT_MS) { get_measurement_result(0) } else { None @@ -1336,7 +1451,7 @@ mod tests { barrier.wait(); // Main thread: wait for worker to need result - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 0); // Verify operations were exported @@ -1383,7 +1498,7 @@ mod tests { worker_barrier.wait(); - assert!(wait_for_result_ready(0, 500)); + assert!(wait_for_result_ready(0, TEST_SYNC_TIMEOUT_MS)); with_interface(|iface| { assert!(iface.operations.is_empty()); @@ -1393,7 +1508,7 @@ mod tests { iface.queue_operation(Operation::Quantum(QuantumOp::H(0))); }); - assert!(wait_for_result_ready(1, 500)); + assert!(wait_for_result_ready(1, TEST_SYNC_TIMEOUT_MS)); with_interface(|iface| { assert!(iface.operations.is_empty()); @@ -1404,7 +1519,7 @@ mod tests { barrier.wait(); - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 0); let ops_ptr = pecos_get_pending_operations(); // SAFETY: see `pecos_get_pending_operations` -- null-or-leaked-Box invariant. @@ -1413,7 +1528,7 @@ mod tests { unsafe { pecos_free_operations(ops_ptr) }; pecos_signal_result_ready(); - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 1); let ops_ptr = pecos_get_pending_operations(); // SAFETY: see `pecos_get_pending_operations` -- null-or-leaked-Box invariant. diff --git a/crates/pecos-qis/Cargo.toml b/crates/pecos-qis/Cargo.toml index d58b89ef1..b56e57d58 100644 --- a/crates/pecos-qis/Cargo.toml +++ b/crates/pecos-qis/Cargo.toml @@ -49,13 +49,13 @@ tempfile.workspace = true rand.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +sha2.workspace = true crossbeam-channel.workspace = true libloading.workspace = true # Inkwell for LLVM support (optional) [dependencies.inkwell] workspace = true -features = ["llvm14-0"] optional = true [dependencies.selene-simple-runtime] diff --git a/crates/pecos-qis/build.rs b/crates/pecos-qis/build.rs index 8b4d9bcc6..bec15897b 100644 --- a/crates/pecos-qis/build.rs +++ b/crates/pecos-qis/build.rs @@ -20,11 +20,18 @@ fn main() { validate_llvm(); // Embed LLVM bin path at compile time for runtime use - if let Ok(llvm_prefix) = env::var("LLVM_SYS_140_PREFIX") { + if let Ok(llvm_prefix) = env::var(pecos_build::llvm::LLVM_SYS_PREFIX_ENV) { let llvm_bin = PathBuf::from(&llvm_prefix).join("bin"); println!("cargo:rustc-env=PECOS_LLVM_BIN_PATH={}", llvm_bin.display()); } + // Embed the build target triple so the QIS program-cache key can scope cached + // shared objects to the exact target ABI (e.g. distinguish + // x86_64-unknown-linux-gnu from -musl), not just arch/os. + if let Ok(target) = env::var("TARGET") { + println!("cargo:rustc-env=PECOS_QIS_TARGET={target}"); + } + // Build Selene-specific components only when the selene feature is enabled #[cfg(feature = "selene")] build_selene::build_selene_components(); @@ -32,57 +39,63 @@ fn main() { #[cfg(feature = "llvm")] fn validate_llvm() { - use pecos_build::llvm::is_valid_llvm_14; + use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, is_valid_llvm}; - // Check if LLVM_SYS_140_PREFIX is already set and valid - if let Ok(sys_prefix) = env::var("LLVM_SYS_140_PREFIX") { + // Check if LLVM_SYS_PREFIX_ENV is already set and valid + if let Ok(sys_prefix) = env::var(LLVM_SYS_PREFIX_ENV) { let path = PathBuf::from(&sys_prefix); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { // LLVM is configured and valid, we're good! return; } eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("ERROR: Invalid LLVM_SYS_140_PREFIX"); + eprintln!("ERROR: Invalid {LLVM_SYS_PREFIX_ENV}"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("LLVM_SYS_140_PREFIX is set to: {sys_prefix}"); - eprintln!("But this is not a valid LLVM 14 installation."); + eprintln!("{LLVM_SYS_PREFIX_ENV} is set to: {sys_prefix}"); + eprintln!("But this is not a valid LLVM {REQUIRED_VERSION} installation."); eprintln!(); eprintln!("Please either:"); - eprintln!(" 1. Fix the path to point to a valid LLVM 14 installation"); + eprintln!(" 1. Fix the path to point to a valid LLVM {REQUIRED_VERSION} installation"); eprintln!(" 2. Unset it and configure LLVM:"); - eprintln!(" unset LLVM_SYS_140_PREFIX"); + eprintln!(" unset {LLVM_SYS_PREFIX_ENV}"); eprintln!(" pecos llvm configure"); eprintln!("═══════════════════════════════════════════════════════════════\n"); - panic!("Invalid LLVM_SYS_140_PREFIX. See error message above."); + panic!("Invalid {LLVM_SYS_PREFIX_ENV}. See error message above."); } - // LLVM_SYS_140_PREFIX not set - print setup instructions + // LLVM_SYS_PREFIX_ENV not set - print setup instructions print_llvm_not_found_error_extended(); - panic!("LLVM 14 not configured. See error message above for setup instructions."); + panic!( + "LLVM {REQUIRED_VERSION} not configured. See error message above for setup instructions." + ); } #[cfg(feature = "llvm")] fn print_llvm_not_found_error_extended() { + use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION}; + eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("LLVM 14 Setup Required"); + eprintln!("LLVM {REQUIRED_VERSION} Setup Required"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("PECOS needs LLVM 14. Choose one of these installation methods:"); + eprintln!("PECOS needs LLVM {REQUIRED_VERSION}. Choose one of these installation methods:"); eprintln!(); eprintln!("Option 1: Use pecos setup (recommended)"); eprintln!(" pecos setup"); eprintln!(" cargo build"); eprintln!(); eprintln!(" This detects and installs all missing dependencies."); - eprintln!(" (LLVM 14: ~400 MB download, installs to ~/.pecos/deps/llvm-14/)"); + eprintln!( + " (LLVM {REQUIRED_VERSION}: several hundred MB download, installs to ~/.pecos/deps/llvm-{REQUIRED_VERSION}/)" + ); eprintln!(); #[cfg(target_os = "macos")] { eprintln!("Option 2: Install via Homebrew"); - eprintln!(" # Install LLVM 14"); - eprintln!(" brew install llvm@14"); + eprintln!(" # Install LLVM 21"); + eprintln!(" brew install llvm@21"); eprintln!(); eprintln!(" # Configure PECOS to use it"); eprintln!(" pecos llvm configure"); @@ -100,14 +113,14 @@ fn print_llvm_not_found_error_extended() { eprintln!(); eprintln!(" Debian/Ubuntu:"); eprintln!(" sudo apt update"); - eprintln!(" sudo apt install llvm-14 llvm-14-dev"); + eprintln!(" sudo apt install llvm-21 llvm-21-dev"); eprintln!(); eprintln!(" Fedora/RHEL:"); - eprintln!(" sudo dnf install llvm14 llvm14-devel"); + eprintln!(" sudo dnf install llvm21 llvm21-devel"); eprintln!(); eprintln!(" Arch Linux:"); - eprintln!(" # LLVM 14 may need to be built from AUR"); - eprintln!(" yay -S llvm14"); + eprintln!(" # LLVM 21 may need to come from an alternate repository"); + eprintln!(" yay -S llvm21"); eprintln!(); eprintln!(" Then configure and build:"); eprintln!(" pecos llvm configure"); @@ -127,7 +140,7 @@ fn print_llvm_not_found_error_extended() { eprintln!(" https://github.com/vovkos/llvm-package-windows"); eprintln!(); eprintln!(" After extracting to C:\\LLVM (or similar):"); - eprintln!(" set LLVM_SYS_140_PREFIX=C:\\LLVM"); + eprintln!(" set {LLVM_SYS_PREFIX_ENV}=C:\\LLVM"); eprintln!(" pecos llvm configure"); eprintln!(" cargo build"); eprintln!(); @@ -137,9 +150,9 @@ fn print_llvm_not_found_error_extended() { eprintln!(" Instead of 'configure', you can set environment variables:"); eprintln!(); #[cfg(target_os = "windows")] - eprintln!(" set LLVM_SYS_140_PREFIX=C:\\path\\to\\llvm"); + eprintln!(" set {LLVM_SYS_PREFIX_ENV}=C:\\path\\to\\llvm"); #[cfg(not(target_os = "windows"))] - eprintln!(" export LLVM_SYS_140_PREFIX=/path/to/llvm"); + eprintln!(" export {LLVM_SYS_PREFIX_ENV}=/path/to/llvm"); #[cfg(not(target_os = "windows"))] eprintln!(" Or add llvm-config to PATH:"); #[cfg(not(target_os = "windows"))] diff --git a/crates/pecos-qis/build_selene.rs b/crates/pecos-qis/build_selene.rs index 11a44ec00..599bc3098 100644 --- a/crates/pecos-qis/build_selene.rs +++ b/crates/pecos-qis/build_selene.rs @@ -4,6 +4,8 @@ //! It is only compiled when the `selene` feature is enabled. use log::info; +#[cfg(target_os = "windows")] +use pecos_build::llvm::LLVM_SYS_PREFIX_ENV; use std::env; use std::path::{Path, PathBuf}; use std::process::Command; @@ -186,7 +188,7 @@ EXPORTS // Try to use llvm-dlltool (from LLVM) or dlltool (from MinGW) to generate import library // First try llvm-dlltool which should be available with our LLVM installation - let dlltool_result = if let Ok(llvm_prefix) = env::var("LLVM_SYS_140_PREFIX") { + let dlltool_result = if let Ok(llvm_prefix) = env::var(LLVM_SYS_PREFIX_ENV) { let llvm_dlltool = PathBuf::from(llvm_prefix) .join("bin") .join("llvm-dlltool.exe"); @@ -208,7 +210,7 @@ EXPORTS } else { Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "LLVM_SYS_140_PREFIX not set", + format!("{LLVM_SYS_PREFIX_ENV} not set"), )) }; diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 5c85fe079..a7c2d02c9 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -23,7 +23,10 @@ use pecos_engines::shot_results::{Data, Shot}; use pecos_engines::{ ByteMessage, ByteMessageBuilder, ClassicalEngine, ControlEngine, Engine, EngineStage, }; -use pecos_qis_ffi_types::{Operation, OperationCollector as OperationList, QuantumOp}; +use pecos_qis_ffi_types::{ + LoweredQuantumOp, NamedResultTrace, Operation, OperationCollector as OperationList, QuantumOp, + TraceMetadata, +}; use pecos_random::PecosRng; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -42,6 +45,8 @@ pub struct LoweredQuantumGateTrace { pub angles: Vec, pub params: Vec, pub qubits: Vec, + pub measurement_result_ids: Vec, + pub metadata: TraceMetadata, } /// One traced batch of QIS operations and their lowered simulator commands. @@ -58,6 +63,7 @@ pub struct OperationTraceChunk { pub num_operations: usize, pub operations: Vec, pub lowered_quantum_ops: Vec, + pub named_result_traces: Vec, } /// Shared in-memory store for traced QIS operation batches. @@ -66,6 +72,12 @@ pub type OperationTraceStore = Arc>>; /// Result from worker thread - returns both the operations and the interface type WorkerResult = Result<(OperationList, BoxedInterface), String>; +/// Simulator commands plus one metadata record per lowered quantum gate. +struct LoweredCommandBatch { + commands: ByteMessage, + gate_metadata: Vec, +} + /// State for dynamic circuit execution /// /// The LLVM program runs in a worker thread. When it needs a measurement result, @@ -197,6 +209,9 @@ pub struct QisEngine { /// qubit handles — use `active_qubit_slots.len()` for that. num_physical_slots: usize, + /// Optional device-size hint supplied by the top-level simulation builder. + num_qubits_hint: Option, + /// Mapping from program-level qubit handles to physical simulator slots. active_qubit_slots: BTreeMap, @@ -320,6 +335,7 @@ impl QisEngine { runtime, current_operations: None, num_physical_slots: 0, + num_qubits_hint: None, active_qubit_slots: BTreeMap::new(), free_qubit_slots: BTreeSet::new(), seen_program_qubits: BTreeSet::new(), @@ -412,6 +428,7 @@ impl QisEngine { runtime, current_operations: None, num_physical_slots: 0, + num_qubits_hint: None, active_qubit_slots: BTreeMap::new(), free_qubit_slots: BTreeSet::new(), seen_program_qubits: BTreeSet::new(), @@ -487,20 +504,27 @@ impl QisEngine { self.num_physical_slots = 0; } - fn allocate_qubit_slot(&mut self, program_id: usize) -> usize { + fn allocate_qubit_slot(&mut self, program_id: usize) -> Result { if let Some(&slot) = self.active_qubit_slots.get(&program_id) { - return slot; + return Ok(slot); } let slot = if let Some(slot) = self.free_qubit_slots.pop_first() { slot } else { + if let Some(limit) = self.num_qubits_hint + && self.num_physical_slots >= limit + { + return Err(PecosError::Generic(format!( + "QIS program requires more than the configured {limit} physical qubit slots while allocating program qubit {program_id}" + ))); + } self.num_physical_slots }; self.num_physical_slots = self.num_physical_slots.max(slot + 1); self.active_qubit_slots.insert(program_id, slot); self.seen_program_qubits.insert(program_id); - slot + Ok(slot) } fn release_qubit_slot(&mut self, program_id: usize) { @@ -520,7 +544,7 @@ impl QisEngine { ))); } - Ok(self.allocate_qubit_slot(program_id)) + self.allocate_qubit_slot(program_id) } /// Convert dynamic QIS operations into a `ByteMessage` for the quantum engine. @@ -530,17 +554,33 @@ impl QisEngine { /// by `sim()` operate on a fixed physical qubit pool, so we must honor /// `AllocateQubit`/`ReleaseQubit` and remap program handles back onto reusable /// physical slots before sending the quantum ops downstream. - fn operations_to_bytemessage(&mut self, ops: &[Operation]) -> Result { + fn push_gate_metadata( + gate_metadata: &mut Vec, + pending_metadata: &mut TraceMetadata, + ) { + gate_metadata.push(std::mem::take(pending_metadata)); + } + + fn operations_to_lowered_commands( + &mut self, + ops: &[Operation], + ) -> Result { let mut builder = std::mem::take(&mut self.command_builder); builder.reset(); self.measurement_mapping.clear(); + let mut gate_metadata = Vec::new(); + let mut pending_metadata = TraceMetadata::new(); let result = (|| -> Result<(), PecosError> { for op in ops { match op { + Operation::TraceMetadata { metadata, .. } => { + pending_metadata.extend(metadata.clone()); + } Operation::AllocateQubit { id } => { - let slot = self.allocate_qubit_slot(*id); + let slot = self.allocate_qubit_slot(*id)?; builder.pz(&[slot]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } Operation::ReleaseQubit { id } => { self.release_qubit_slot(*id); @@ -551,45 +591,56 @@ impl QisEngine { Operation::Quantum(qop) => match qop { QuantumOp::H(qubit) => { builder.h(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::X(qubit) => { builder.x(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Y(qubit) => { builder.y(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Z(qubit) => { builder.z(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::S(qubit) => { builder.sz(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Sdg(qubit) => { builder.szdg(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::T(qubit) => { builder.t(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Tdg(qubit) => { builder.tdg(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RX(angle, qubit) => { builder.rx( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RY(angle, qubit) => { builder.ry( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RZ(angle, qubit) => { builder.rz( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RXY(theta, phi, qubit) => { builder.r1xy( @@ -597,22 +648,30 @@ impl QisEngine { Angle64::from_radians(*phi), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); + } + QuantumOp::Idle(duration, qubit) => { + builder.idle(*duration, &[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::CX(control, target) => { builder.cx(&[( self.mapped_qubit(*control, qop)?, self.mapped_qubit(*target, qop)?, )]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Measure(qubit, result_id) => { self.measurement_mapping.push(*result_id); builder.mz(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[( self.mapped_qubit(*qubit1, qop)?, self.mapped_qubit(*qubit2, qop)?, )]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RZZ(angle, qubit1, qubit2) => { builder.rzz( @@ -622,9 +681,11 @@ impl QisEngine { self.mapped_qubit(*qubit2, qop)?, )], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Reset(qubit) => { builder.pz(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } _ => { return Err(PecosError::Generic(format!( @@ -635,61 +696,101 @@ impl QisEngine { } } + if !pending_metadata.is_empty() { + warn!( + "QIS operation trace metadata was not followed by a lowerable quantum operation" + ); + } Ok(()) })(); - let message = result.map(|()| builder.build()); + let message = result.map(|()| LoweredCommandBatch { + commands: builder.build(), + gate_metadata, + }); self.command_builder = builder; message } + /// Convert freshly collected dynamic operations into a `ByteMessage`. + /// + /// Selene runtime plugins can opt in to lowering so their scheduler sees + /// the same operation stream that Selene would receive. Other runtimes keep + /// using PECOS's direct QIS lowering path. + fn lower_operations_to_commands( + &mut self, + ops: &[Operation], + ) -> Result { + if self.runtime.supports_operation_lowering() { + let lowered_ops = self + .runtime + .lower_operations_with_metadata(ops) + .map_err(|e| PecosError::Generic(format!("Runtime lowering error: {e}")))?; + return self.quantum_ops_to_lowered_commands(lowered_ops); + } + + self.operations_to_lowered_commands(ops) + } + /// Convert already-materialized quantum ops into a `ByteMessage`. /// /// This path is used by runtimes that already present qubit ids in the fixed /// simulator space, so no allocate/release remapping is needed. - fn quantum_ops_to_bytemessage( + fn quantum_ops_to_lowered_commands( &mut self, - ops: Vec, - ) -> Result { + ops: Vec, + ) -> Result { let mut builder = std::mem::take(&mut self.command_builder); builder.reset(); self.measurement_mapping.clear(); + let mut gate_metadata = Vec::new(); let result = (|| -> Result<(), PecosError> { - for op in ops { + for LoweredQuantumOp { op, metadata } in ops { match op { QuantumOp::H(qubit) => { builder.h(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::X(qubit) => { builder.x(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Y(qubit) => { builder.y(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Z(qubit) => { builder.z(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::S(qubit) => { builder.sz(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Sdg(qubit) => { builder.szdg(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::T(qubit) => { builder.t(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Tdg(qubit) => { builder.tdg(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::RX(angle, qubit) => { builder.rx(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RY(angle, qubit) => { builder.ry(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RZ(angle, qubit) => { builder.rz(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RXY(theta, phi, qubit) => { builder.r1xy( @@ -697,22 +798,32 @@ impl QisEngine { Angle64::from_radians(phi), &[qubit], ); + gate_metadata.push(metadata); + } + QuantumOp::Idle(duration, qubit) => { + builder.idle(duration, &[qubit]); + gate_metadata.push(metadata); } QuantumOp::CX(control, target) => { builder.cx(&[(control, target)]); + gate_metadata.push(metadata); } QuantumOp::Measure(qubit, result_id) => { self.measurement_mapping.push(result_id); builder.mz(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[(qubit1, qubit2)]); + gate_metadata.push(metadata); } QuantumOp::RZZ(angle, qubit1, qubit2) => { builder.rzz(Angle64::from_radians(angle), &[(qubit1, qubit2)]); + gate_metadata.push(metadata); } QuantumOp::Reset(qubit) => { builder.pz(&[qubit]); + gate_metadata.push(metadata); } _ => { return Err(PecosError::Generic(format!( @@ -725,10 +836,21 @@ impl QisEngine { Ok(()) })(); - let message = result.map(|()| builder.build()); + let message = result.map(|()| LoweredCommandBatch { + commands: builder.build(), + gate_metadata, + }); self.command_builder = builder; message } + + fn quantum_ops_to_bytemessage( + &mut self, + ops: Vec, + ) -> Result { + self.quantum_ops_to_lowered_commands(ops.into_iter().map(LoweredQuantumOp::from).collect()) + .map(|lowered| lowered.commands) + } } impl Clone for QisEngine { @@ -760,6 +882,7 @@ impl Clone for QisEngine { runtime: dyn_clone::clone_box(&*self.runtime), current_operations: self.current_operations.clone(), num_physical_slots: self.num_physical_slots, + num_qubits_hint: self.num_qubits_hint, active_qubit_slots: self.active_qubit_slots.clone(), free_qubit_slots: self.free_qubit_slots.clone(), seen_program_qubits: self.seen_program_qubits.clone(), @@ -799,25 +922,66 @@ impl QisEngine { self.trace_chunk_index = 0; } - fn lowered_quantum_ops_trace(commands: &ByteMessage) -> Vec { + fn lowered_quantum_ops_trace( + commands: &ByteMessage, + measurement_mapping: &[usize], + gate_metadata: &[TraceMetadata], + ) -> Vec { match commands.quantum_ops() { - Ok(gates) => gates - .iter() - .map(|gate| LoweredQuantumGateTrace { - gate_type: gate.gate_type.to_string(), - angles: gate - .angles - .iter() - .map(Angle64::to_radians) - .collect::>(), - params: gate.params.iter().copied().collect::>(), - qubits: gate + Ok(gates) => { + let mut measurement_cursor = 0usize; + let mut traces = Vec::with_capacity(gates.len()); + if gate_metadata.len() != gates.len() { + warn!( + "Lowered operation trace has {} metadata record(s) for {} gate(s)", + gate_metadata.len(), + gates.len() + ); + } + for (gate_index, gate) in gates.iter().enumerate() { + let gate_type = gate.gate_type.to_string(); + let qubits = gate .qubits .iter() .map(|q| usize::from(*q)) - .collect::>(), - }) - .collect::>(), + .collect::>(); + let measurement_result_ids = if gate_type == "MZ" { + let end = measurement_cursor + qubits.len(); + if end > measurement_mapping.len() { + warn!( + "Lowered operation trace has more measured qubits than result-id mappings" + ); + Vec::new() + } else { + let ids = measurement_mapping[measurement_cursor..end].to_vec(); + measurement_cursor = end; + ids + } + } else { + Vec::new() + }; + traces.push(LoweredQuantumGateTrace { + gate_type, + angles: gate + .angles + .iter() + .map(Angle64::to_radians) + .collect::>(), + params: gate.params.iter().copied().collect::>(), + qubits, + measurement_result_ids, + metadata: gate_metadata.get(gate_index).cloned().unwrap_or_default(), + }); + } + if measurement_cursor != measurement_mapping.len() { + warn!( + "Lowered operation trace consumed {} measurement mapping(s), but {} were present", + measurement_cursor, + measurement_mapping.len() + ); + } + traces + } Err(err) => { warn!("Failed to parse lowered quantum ops for tracing: {err}"); Vec::new() @@ -830,14 +994,20 @@ impl QisEngine { stage: &str, ops: &[Operation], waiting_for_result_id: Option, - lowered_quantum_ops: Option<&ByteMessage>, + lowered_quantum_ops: Option<&LoweredCommandBatch>, ) { if self.operation_trace_dir.is_none() && self.operation_trace_collector.is_none() { return; } let lowered_trace = lowered_quantum_ops - .map(Self::lowered_quantum_ops_trace) + .map(|lowered| { + Self::lowered_quantum_ops_trace( + &lowered.commands, + &self.measurement_mapping, + &lowered.gate_metadata, + ) + }) .unwrap_or_default(); let file_name = format!( "engine_{:04}_shot_{:06}_chunk_{:04}_{}.json", @@ -860,6 +1030,7 @@ impl QisEngine { num_operations: ops.len(), operations: ops.to_vec(), lowered_quantum_ops: lowered_trace, + named_result_traces: Vec::new(), }; if let Some(ref collector) = self.operation_trace_collector { @@ -899,6 +1070,92 @@ impl QisEngine { } } + fn trace_named_result_traces_chunk(&mut self, named_result_traces: &[NamedResultTrace]) { + if named_result_traces.is_empty() + || (self.operation_trace_dir.is_none() && self.operation_trace_collector.is_none()) + { + return; + } + + let stage = "named_results"; + let file_name = format!( + "engine_{:04}_shot_{:06}_chunk_{:04}_{}.json", + self.trace_engine_id, self.trace_shot_index, self.trace_chunk_index, stage + ); + let chunk_index = self.trace_chunk_index; + self.trace_chunk_index = self + .trace_chunk_index + .checked_add(1) + .expect("trace_chunk_index overflow: too many chunks for a single trace shot"); + let chunk = OperationTraceChunk { + format: "pecos_qis_operation_trace_v1", + engine_trace_id: self.trace_engine_id, + shot_index: self.trace_shot_index, + chunk_index, + stage: stage.to_string(), + waiting_for_result_id: None, + current_shot_seed: self.current_shot_seed, + simulated_op_count: self.simulated_op_count, + num_operations: 0, + operations: Vec::new(), + lowered_quantum_ops: Vec::new(), + named_result_traces: named_result_traces.to_vec(), + }; + + if let Some(ref collector) = self.operation_trace_collector { + match collector.lock() { + Ok(mut guard) => guard.push(chunk.clone()), + Err(err) => warn!("Failed to store named result trace chunk in memory: {err}"), + } + } + + if let Some(ref trace_dir) = self.operation_trace_dir { + if let Err(err) = fs::create_dir_all(trace_dir) { + warn!( + "Failed to create operation trace directory {}: {err}", + trace_dir.display() + ); + return; + } + + let trace_path = trace_dir.join(file_name); + let serialized = match serde_json::to_string_pretty(&chunk) { + Ok(serialized) => serialized, + Err(err) => { + warn!( + "Failed to serialize named result trace chunk for {}: {err}", + trace_path.display() + ); + return; + } + }; + + if let Err(err) = fs::write(&trace_path, serialized) { + warn!( + "Failed to write named result trace chunk {}: {err}", + trace_path.display() + ); + } + } + } + + fn trace_named_result_traces_from_dynamic_handle(&mut self) { + let named_result_traces = if let Some(state) = &self.dynamic_state + && let Some(handle) = &state.sync_handle + { + match handle.get_named_result_traces() { + Ok(named_result_traces) => named_result_traces, + Err(e) => { + debug!("QisEngine: Failed to get named result traces: {e}"); + Vec::new() + } + } + } else { + Vec::new() + }; + self.trace_named_result_traces_chunk(&named_result_traces); + } + /// Start the LLVM program execution in a worker thread /// /// Uses a persistent worker thread to avoid TLS allocation issues from @@ -1120,11 +1377,26 @@ impl ClassicalEngine for QisEngine { // return the physical-slot high-water mark instead. The runtime can // report its own baseline (e.g. from `allocated_qubits` metadata) and // we take the larger of the two. - let num_qubits = self.runtime.num_qubits().max(self.num_physical_slots); + let num_qubits = if let Some(hint) = self.num_qubits_hint { + hint + } else { + self.runtime.num_qubits().max(self.num_physical_slots) + }; debug!("QisEngine: num_qubits() returning {num_qubits}"); num_qubits } + /// QIS programs allocate qubits dynamically during execution, so a + /// pre-execution count of 0 means "not yet discovered", not "zero qubits". + fn has_dynamic_qubit_count(&self) -> bool { + true + } + + fn set_num_qubits_hint(&mut self, num_qubits: usize) { + self.num_qubits_hint = Some(num_qubits); + self.runtime.set_num_qubits(num_qubits); + } + fn set_seed(&mut self, seed: u64) { // Seed the RNG for generating per-shot seeds self.rng = PecosRng::seed_from_u64(seed); @@ -1346,14 +1618,14 @@ impl ControlEngine for QisEngine { // Track how many operations we're sending for simulation self.simulated_op_count = ops.len(); if !ops.is_empty() { - let commands = self.operations_to_bytemessage(&ops)?; + let lowered = self.lower_operations_to_commands(&ops)?; self.trace_operations_chunk( "pending_start", &ops, Some(result_id), - Some(&commands), + Some(&lowered), ); - return Ok(EngineStage::NeedsProcessing(commands)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } } @@ -1365,11 +1637,12 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } @@ -1411,11 +1684,12 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } @@ -1455,14 +1729,14 @@ impl ControlEngine for QisEngine { if let Some(ops) = self.get_dynamic_operations() { self.simulated_op_count += ops.len(); if !ops.is_empty() { - let commands = self.operations_to_bytemessage(&ops)?; + let lowered = self.lower_operations_to_commands(&ops)?; self.trace_operations_chunk( "pending_continue", &ops, Some(result_id), - Some(&commands), + Some(&lowered), ); - return Ok(EngineStage::NeedsProcessing(commands)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } } @@ -1475,11 +1749,12 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } @@ -1556,12 +1831,13 @@ mod tests { let ops = vec![ Operation::AllocateQubit { id: 0 }, QuantumOp::H(0).into(), + QuantumOp::Idle(20e-9, 0).into(), QuantumOp::Measure(0, 7).into(), ]; - let commands = engine - .operations_to_bytemessage(&ops) - .expect("convert ops to bytemessage"); - engine.trace_operations_chunk("unit_test", &ops, Some(7), Some(&commands)); + let lowered = engine + .operations_to_lowered_commands(&ops) + .expect("convert ops to lowered commands"); + engine.trace_operations_chunk("unit_test", &ops, Some(7), Some(&lowered)); let mut trace_files = std::fs::read_dir(temp_dir.path()) .expect("read trace dir") @@ -1578,17 +1854,169 @@ mod tests { assert_eq!(value["shot_index"], 1); assert_eq!(value["waiting_for_result_id"], 7); assert_eq!(value["current_shot_seed"], 123); - assert_eq!(value["num_operations"], 3); + assert_eq!(value["num_operations"], 4); assert_eq!(value["operations"][0]["AllocateQubit"]["id"], 0); assert_eq!(value["operations"][1]["Quantum"]["H"], 0); + assert_eq!(value["operations"][2]["Quantum"]["Idle"][0], 20e-9); assert_eq!(value["lowered_quantum_ops"][0]["gate_type"], "PZ"); + assert_eq!( + value["lowered_quantum_ops"][0]["metadata"], + serde_json::json!({}) + ); assert_eq!(value["lowered_quantum_ops"][1]["gate_type"], "H"); - assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "MZ"); + assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "Idle"); + assert_eq!(value["lowered_quantum_ops"][2]["params"][0], 20e-9); + assert_eq!(value["lowered_quantum_ops"][3]["gate_type"], "MZ"); + assert_eq!( + value["lowered_quantum_ops"][3]["measurement_result_ids"], + serde_json::json!([7]) + ); let in_memory = collector.lock().expect("collector lock"); assert_eq!(in_memory.len(), 1); assert_eq!(in_memory[0].stage, "unit_test"); assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "PZ"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "Idle"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].params, vec![20e-9]); + assert_eq!( + in_memory[0].lowered_quantum_ops[3].measurement_result_ids, + vec![7] + ); + } + + #[test] + fn test_direct_lowering_attaches_trace_metadata_to_next_gate() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + let collector: OperationTraceStore = Arc::new(Mutex::new(Vec::new())); + engine.set_operation_trace_collector(collector.clone()); + engine.begin_trace_shot(); + + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "szz_physical_prefix:H:X0:q0".to_string(), + ); + metadata.insert("source_kind".to_string(), "szz_prefix".to_string()); + let ops = vec![ + Operation::TraceMetadata { + metadata, + qubit: None, + }, + QuantumOp::H(0).into(), + QuantumOp::Measure(0, 7).into(), + ]; + + let lowered = engine + .operations_to_lowered_commands(&ops) + .expect("convert ops to lowered commands"); + engine.trace_operations_chunk("unit_test", &ops, None, Some(&lowered)); + + let in_memory = collector.lock().expect("collector lock"); + assert_eq!(in_memory.len(), 1); + assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "H"); + assert_eq!( + in_memory[0].lowered_quantum_ops[0] + .metadata + .get("source_label"), + Some(&"szz_physical_prefix:H:X0:q0".to_string()) + ); + assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "MZ"); + assert!(in_memory[0].lowered_quantum_ops[1].metadata.is_empty()); + } + + #[derive(Clone, Default)] + struct IdleLoweringRuntime { + state: ClassicalState, + } + + impl QisRuntime for IdleLoweringRuntime { + fn load_interface(&mut self, _interface: OperationList) -> RuntimeResult<()> { + Ok(()) + } + + fn execute_until_quantum(&mut self) -> RuntimeResult>> { + Ok(None) + } + + fn provide_measurements( + &mut self, + _measurements: BTreeMap, + ) -> RuntimeResult<()> { + Ok(()) + } + + fn get_classical_state(&self) -> &ClassicalState { + &self.state + } + + fn get_classical_state_mut(&mut self) -> &mut ClassicalState { + &mut self.state + } + + fn is_complete(&self) -> bool { + true + } + + fn num_qubits(&self) -> usize { + 1 + } + + fn supports_operation_lowering(&self) -> bool { + true + } + + fn lower_operations(&mut self, _operations: &[Operation]) -> RuntimeResult> { + Ok(vec![ + QuantumOp::Idle(20e-9, 0), + QuantumOp::H(0), + QuantumOp::Measure(0, 17), + ]) + } + + fn lower_operations_with_metadata( + &mut self, + _operations: &[Operation], + ) -> RuntimeResult> { + let mut idle_metadata = TraceMetadata::new(); + idle_metadata.insert("runtime_stage".to_string(), "scheduled_idle".to_string()); + Ok(vec![ + LoweredQuantumOp::new(QuantumOp::Idle(20e-9, 0), idle_metadata), + QuantumOp::H(0).into(), + QuantumOp::Measure(0, 17).into(), + ]) + } + } + + #[test] + fn test_operation_trace_chunk_includes_runtime_lowered_idles() { + let mut engine = QisEngine::with_runtime(Box::new(IdleLoweringRuntime::default())); + let collector: OperationTraceStore = Arc::new(Mutex::new(Vec::new())); + engine.set_operation_trace_collector(collector.clone()); + engine.begin_trace_shot(); + + let ops = vec![QuantumOp::H(0).into()]; + let lowered = engine + .lower_operations_to_commands(&ops) + .expect("runtime lower ops to commands"); + engine.trace_operations_chunk("unit_test", &ops, None, Some(&lowered)); + + let in_memory = collector.lock().expect("collector lock"); + assert_eq!(in_memory.len(), 1); + assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "Idle"); + assert_eq!(in_memory[0].lowered_quantum_ops[0].params, vec![20e-9]); + assert_eq!(in_memory[0].lowered_quantum_ops[0].qubits, vec![0]); + assert_eq!( + in_memory[0].lowered_quantum_ops[0] + .metadata + .get("runtime_stage"), + Some(&"scheduled_idle".to_string()) + ); + assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "H"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "MZ"); + assert_eq!( + in_memory[0].lowered_quantum_ops[2].measurement_result_ids, + vec![17] + ); } #[test] @@ -1596,11 +2024,14 @@ mod tests { let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); let ops = vec![QuantumOp::H(0).into(), QuantumOp::Measure(0, 7).into()]; - let commands = engine - .operations_to_bytemessage(&ops) + let lowered_commands = engine + .operations_to_lowered_commands(&ops) .expect("convert ops with implicit static handles"); - let lowered = commands.quantum_ops().expect("parse lowered commands"); + let lowered = lowered_commands + .commands + .quantum_ops() + .expect("parse lowered commands"); assert_eq!(lowered.len(), 2); assert_eq!(lowered[0].gate_type.to_string(), "H"); assert_eq!(lowered[0].qubits.as_slice(), &[pecos_core::QubitId(0)]); @@ -1618,7 +2049,7 @@ mod tests { QuantumOp::X(0).into(), ]; - let Err(err) = engine.operations_to_bytemessage(&ops) else { + let Err(err) = engine.operations_to_lowered_commands(&ops) else { panic!("released qubit reuse should error"); }; @@ -1627,4 +2058,51 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn test_num_qubits_hint_is_physical_capacity_for_sparse_handles() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + engine.set_num_qubits_hint(98); + let ops = vec![ + Operation::AllocateQubit { id: 81 }, + Operation::AllocateQubit { id: 105 }, + QuantumOp::CX(81, 105).into(), + ]; + + let lowered_commands = engine + .operations_to_lowered_commands(&ops) + .expect("sparse handles should map onto live physical slots"); + + let lowered = lowered_commands + .commands + .quantum_ops() + .expect("parse lowered commands"); + assert_eq!(lowered[0].qubits.as_slice(), &[pecos_core::QubitId(0)]); + assert_eq!(lowered[1].qubits.as_slice(), &[pecos_core::QubitId(1)]); + assert_eq!( + lowered[2].qubits.as_slice(), + &[pecos_core::QubitId(0), pecos_core::QubitId(1)] + ); + assert_eq!(engine.num_physical_slots, 2); + assert_eq!(engine.num_qubits(), 98); + } + + #[test] + fn test_qubit_hint_rejects_too_many_live_physical_slots() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + engine.set_num_qubits_hint(1); + let ops = vec![ + Operation::AllocateQubit { id: 81 }, + Operation::AllocateQubit { id: 105 }, + ]; + + let Err(err) = engine.operations_to_lowered_commands(&ops) else { + panic!("allocating beyond the physical qubit hint should error"); + }; + + assert!( + err.to_string().contains("more than the configured 1"), + "unexpected error: {err}" + ); + } } diff --git a/crates/pecos-qis/src/engine_builder.rs b/crates/pecos-qis/src/engine_builder.rs index adc994260..d4384aa2c 100644 --- a/crates/pecos-qis/src/engine_builder.rs +++ b/crates/pecos-qis/src/engine_builder.rs @@ -14,6 +14,9 @@ pub struct QisEngineBuilder { program_source: Option, // Store original program source for loading operation_trace_dir: Option, operation_trace_collector: Option, + /// `QSystem` platform used when lowering HUGR programs (defaults to Helios). + #[cfg(feature = "hugr")] + platform: pecos_hugr_qis::QSystemPlatform, } impl Clone for QisEngineBuilder { @@ -29,6 +32,8 @@ impl Clone for QisEngineBuilder { program_source: self.program_source.clone(), operation_trace_dir: self.operation_trace_dir.clone(), operation_trace_collector: self.operation_trace_collector.clone(), + #[cfg(feature = "hugr")] + platform: self.platform, } } } @@ -44,9 +49,25 @@ impl QisEngineBuilder { program_source: None, operation_trace_dir: None, operation_trace_collector: None, + // PECOS targets the Selene Helios QIS runtime by default. + #[cfg(feature = "hugr")] + platform: pecos_hugr_qis::QSystemPlatform::Helios, } } + /// Select the `QSystem` platform used when lowering HUGR programs. + /// + /// Defaults to [`pecos_hugr_qis::QSystemPlatform::Helios`]. Selecting another + /// supported platform (e.g. `Sol`) lowers both the executed QIS and the + /// interface for that platform; a matching Selene runtime is required to + /// execute the result. + #[cfg(feature = "hugr")] + #[must_use] + pub fn platform(mut self, platform: pecos_hugr_qis::QSystemPlatform) -> Self { + self.platform = platform; + self + } + /// Dump Helios-collected operation chunks to the given directory as JSON. /// /// This captures the lowered QIS operation stream before it is compressed into @@ -213,9 +234,15 @@ impl QisEngineBuilder { } else if let Some(hugr_prog) = any_program.downcast_ref::() { #[cfg(feature = "hugr")] { - self.program_source = Some(pecos_hugr_qis::compile_hugr_bytes_to_string( - &hugr_prog.hugr, - )?); + let args = pecos_hugr_qis::CompileArgs { + platform: self.platform, + ..Default::default() + }; + self.program_source = + Some(pecos_hugr_qis::compile_hugr_bytes_to_string_with_options( + &hugr_prog.hugr, + &args, + )?); } #[cfg(not(feature = "hugr"))] { @@ -233,9 +260,16 @@ impl QisEngineBuilder { if let Some(qis_prog) = any_program.downcast_ref::() { log::debug!("Building interface from QIS program"); builder.build_from_qis_program(qis_prog.clone())? - } else if let Some(hugr_prog) = any_program.downcast_ref::() { - log::debug!("Building interface from HUGR program"); - builder.build_from_hugr_program(hugr_prog.clone())? + } else if any_program.is::() { + // `program_source` already holds the QIS lowered with the + // selected platform above; build the interface from it + // instead of re-compiling, so the interface and the executed + // QIS stay on the same platform. + log::debug!("Building interface from compiled HUGR program source"); + let source = self.program_source.clone().ok_or_else(|| { + PecosError::Processing("HUGR program produced no QIS source".to_string()) + })?; + builder.build_from_qis_program(pecos_programs::Qis::from_string(&source))? } else { // Unknown type, use default conversion with the default backend (Helios) log::debug!("Unknown program type, using into_qis_interface"); diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index d1ba56774..5a135c1cd 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -43,6 +43,19 @@ static QIS_FFI_LIB_SINGLETON: OnceLock> = OnceLock /// By making it a singleton, we load it once and keep it for the process lifetime. static SHIM_LIB_SINGLETON: OnceLock> = OnceLock::new(); +/// Pinned selected library paths, resolved once at first use. +/// +/// The QIS FFI and selene shim libraries are discovered at runtime (library +/// search order, and for the shim the `PECOS_SELENE_SHIM_PATH` override). Both +/// the program-cache key (which hashes the selected library identity) and the +/// `dlopen` singletons must agree on WHICH library is selected; if discovery +/// re-ran independently and the environment changed in between, the key could be +/// computed for one library while another was loaded. Pinning the discovery +/// result the first time either path is needed keeps them consistent for the +/// process lifetime. +static QIS_FFI_LIB_PATH: OnceLock> = OnceLock::new(); +static SHIM_LIB_PATH: OnceLock> = OnceLock::new(); + /// Process-wide cache for program libraries (keyed by file path). /// /// When engines are cloned for parallel shot execution, each clone creates its own @@ -64,7 +77,7 @@ static PROGRAM_LIB_CACHE: OnceLock< /// engines are cloned for parallel execution), this cache ensures they all use /// the same compiled shared library file. static COMPILED_PROGRAM_CACHE: OnceLock< - std::sync::Mutex>, + std::sync::Mutex>, > = OnceLock::new(); /// Tracks whether cache cleanup has been performed (once per process). @@ -97,6 +110,68 @@ fn get_persistent_cache_dir() -> Result { Ok(cache_dir) } +/// A fingerprint of the running build, mixed into the persistent compiled-program +/// cache key so a shared object produced by one build is never reused by a +/// different build whose QIS/runtime/ABI may differ (the cache directory is a +/// fixed path shared across worktrees and rebuilds). +/// +/// Derived from the running executable's identity (path + last-modified time): +/// stable across processes of the same build, but different whenever the binary +/// is rebuilt. If the executable cannot be identified, the process id is mixed in +/// instead so a stale object is never shared across processes with a possibly +/// different ABI (at the cost of in-build reuse for that process). +fn build_fingerprint() -> u64 { + use std::hash::{Hash, Hasher}; + static BUILD_FINGERPRINT: OnceLock = OnceLock::new(); + *BUILD_FINGERPRINT.get_or_init(|| { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + match std::env::current_exe() { + Ok(exe) => { + exe.hash(&mut hasher); + let mtime_nanos = std::fs::metadata(&exe) + .and_then(|m| m.modified()) + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|since_epoch| since_epoch.as_nanos()); + mtime_nanos.hash(&mut hasher); + } + Err(_) => { + std::process::id().hash(&mut hasher); + } + } + hasher.finish() + }) +} + +/// The build target triple (e.g. `x86_64-unknown-linux-gnu`), embedded by the +/// build script, mixed into the cache key and recorded in the manifest so a +/// shared object compiled for one target ABI is never reused on another. Using +/// the full triple distinguishes ABIs a coarse `arch-os` would collapse (e.g. +/// gnu vs musl). +fn cache_target() -> String { + env!("PECOS_QIS_TARGET").to_string() +} + +/// An auditable record written next to each compiled program object in the +/// shared cache directory. It is not consulted to validate a load -- the cache +/// filename is the full SHA-256 of `(program, format, build fingerprint)`, so a +/// filename match already pins those exactly -- but it lets the shared cache +/// directory be inspected and each `.so` traced back to the program, format, +/// build, and toolchain that produced it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct CacheManifest { + /// Full SHA-256 (hex) of program bytes + format + build fingerprint. + digest: String, + /// Program format (stable `Debug` variant name). + format: String, + /// Build fingerprint of the process that compiled the object. + build_fingerprint: u64, + /// `pecos-qis` version that compiled the object. + pecos_qis_version: String, + /// Target arch/os the object was compiled for. + target: String, +} + /// Remove cache files older than the specified age in seconds fn cleanup_old_cache_files(cache_dir: &Path, max_age_secs: u64) { let now = std::time::SystemTime::now(); @@ -303,6 +378,7 @@ type SetMeasurementResultFn = unsafe extern "C" fn(u64, bool); type SignalResultReadyFn = unsafe extern "C" fn(); type AbortExecutionFn = unsafe extern "C" fn(); type GetNamedResultsJsonFn = unsafe extern "C" fn() -> *mut std::ffi::c_char; +type GetNamedResultTracesJsonFn = unsafe extern "C" fn() -> *mut std::ffi::c_char; type FreeNamedResultsJsonFn = unsafe extern "C" fn(*mut std::ffi::c_char); /// Synchronization handle for main thread communication with worker thread @@ -454,6 +530,55 @@ impl DynamicSyncHandle for HeliosSyncHandle { debug!("HeliosSyncHandle: Got {} named results", result.len()); Ok(result) } + + fn get_named_result_traces( + &self, + ) -> Result, InterfaceError> { + let lib = Self::get_lib()?; + + let get_fn: Symbol = unsafe { + lib.get(b"pecos_get_named_result_traces_json\0") + .map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_get_named_result_traces_json: {e}" + )) + })? + }; + + let ptr = unsafe { get_fn() }; + if ptr.is_null() { + return Ok(Vec::new()); + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(ptr) }; + let json_str = c_str.to_str().map_err(|e| { + InterfaceError::ExecutionError(format!( + "Invalid UTF-8 in named result traces JSON: {e}" + )) + })?; + + let result: Vec = serde_json::from_str(json_str) + .map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to parse named result traces JSON: {e}" + )) + })?; + + let free_fn: Symbol = unsafe { + lib.get(b"pecos_free_named_results_json\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_free_named_results_json: {e}" + )) + })? + }; + unsafe { free_fn(ptr) }; + + debug!( + "HeliosSyncHandle: Got {} named result trace records", + result.len() + ); + Ok(result) + } } /// Derive the project target directory from the compile-time embedded Helios path. @@ -593,7 +718,7 @@ fn find_helios_lib() -> Result { /// Find an LLVM tool with the following priority: /// 1. Embedded path from build time (`PECOS_LLVM_BIN_PATH`) -/// 2. Runtime `LLVM_SYS_140_PREFIX` environment variable +/// 2. Runtime `LLVM_SYS_211_PREFIX` environment variable /// 3. Fall back to PATH fn find_llvm_tool(tool_name: &str) -> PathBuf { let tool_exe = if cfg!(windows) { @@ -617,13 +742,13 @@ fn find_llvm_tool(tool_name: &str) -> PathBuf { } }) .or_else(|| { - std::env::var("LLVM_SYS_140_PREFIX") + std::env::var("LLVM_SYS_211_PREFIX") .ok() .and_then(|prefix| { let path = PathBuf::from(prefix).join("bin").join(&tool_exe); if path.exists() { debug!( - "Using {} from LLVM_SYS_140_PREFIX: {}", + "Using {} from LLVM_SYS_211_PREFIX: {}", tool_name, path.display() ); @@ -711,6 +836,24 @@ impl QisHeliosInterface { } } + /// The selected QIS FFI library path, resolved once and pinned for the + /// process lifetime (see [`QIS_FFI_LIB_PATH`]). Both the cache key and the + /// `dlopen` singleton use this so they always agree on the selected library. + fn pinned_qis_ffi_lib_path() -> Result { + QIS_FFI_LIB_PATH + .get_or_init(|| Self::find_pecos_qis_lib().map_err(|e| e.to_string())) + .clone() + } + + /// The selected selene shim library path, resolved once and pinned for the + /// process lifetime (see [`SHIM_LIB_PATH`]). Honors `PECOS_SELENE_SHIM_PATH` + /// at first resolution only, so the cache key and the loaded shim agree. + fn pinned_shim_lib_path() -> Option { + SHIM_LIB_PATH + .get_or_init(crate::shim::get_shim_library_path) + .clone() + } + /// Find the `libpecos_qis_ffi` library by searching common locations fn find_pecos_qis_lib() -> Result { // On Windows, Rust cdylibs don't use the "lib" prefix @@ -818,7 +961,7 @@ impl QisHeliosInterface { /// /// Returns a reference to the `SharedLibrary` wrapper for symbol lookups. fn get_qis_ffi_lib_singleton() -> Result<&'static SharedLibrary, InterfaceError> { - let result = QIS_FFI_LIB_SINGLETON.get_or_init(|| match Self::find_pecos_qis_lib() { + let result = QIS_FFI_LIB_SINGLETON.get_or_init(|| match Self::pinned_qis_ffi_lib_path() { Ok(lib_path) => { debug!( "Initializing QIS FFI library singleton from: {}", @@ -839,7 +982,7 @@ impl QisHeliosInterface { Err(e) => Err(e.to_string()), } } - Err(e) => Err(e.to_string()), + Err(e) => Err(e.clone()), }); result @@ -913,7 +1056,7 @@ impl QisHeliosInterface { /// By making it a singleton, we load once and keep it for the process lifetime. fn get_shim_lib_singleton() -> Result<&'static SharedLibrary, InterfaceError> { let result = SHIM_LIB_SINGLETON.get_or_init(|| { - let shim_path = crate::shim::get_shim_library_path().ok_or_else(|| { + let shim_path = Self::pinned_shim_lib_path().ok_or_else(|| { "PECOS selene shim library not found - build script may have failed".to_string() })?; @@ -1012,11 +1155,13 @@ impl QisHeliosInterface { // We use AddDllDirectory to temporarily add the directories containing // our FFI DLLs to the search path. - // Find the directories containing our dependency DLLs - let qis_ffi_path = Self::find_pecos_qis_lib().ok(); + // Find the directories containing our dependency DLLs (use the pinned + // paths so dependency search dirs match the libraries hashed in the cache + // key and loaded by the singletons). + let qis_ffi_path = Self::pinned_qis_ffi_lib_path().ok(); let qis_ffi_dir = qis_ffi_path.as_ref().and_then(|p| p.parent()); - let shim_path = crate::shim::get_shim_library_path(); + let shim_path = Self::pinned_shim_lib_path(); let shim_dir = shim_path.as_ref().and_then(|p| p.parent()); // Combine both directories (they may be different) @@ -1205,15 +1350,56 @@ impl QisHeliosInterface { /// Link the program with Helios interface to create a shared library #[allow(clippy::too_many_lines)] fn create_shared_library(&mut self) -> Result { - use std::hash::{Hash, Hasher}; - - // Compute content hash for caching - // We include the format as a discriminator in case the same bytes could be - // interpreted differently (e.g., bitcode vs text IR) - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.program.hash(&mut hasher); - std::mem::discriminant(&self.format).hash(&mut hasher); - let content_hash = hasher.finish(); + use sha2::{Digest, Sha256}; + use std::fmt::Write as _; + + // Compute a stable content digest for caching. + // - SHA-256 (not the std `DefaultHasher`, whose output is not stable + // across toolchains) so the on-disk key is reproducible for a cache + // that persists across processes + // - the format discriminates bytes that could be interpreted differently + // (e.g., bitcode vs text IR) + // - the build fingerprint scopes the digest to this build, so a shared + // object compiled against a different QIS/runtime ABI in the fixed, + // cross-worktree cache directory is never reused + let mut hasher = Sha256::new(); + hasher.update(&self.program); + // Explicit ABI inputs (stable format tag, crate version, target triple) + // in addition to the build fingerprint, so the key does not rely on the + // running-executable mtime proxy alone to scope reuse. + hasher.update(self.format.cache_tag().as_bytes()); + hasher.update(env!("CARGO_PKG_VERSION").as_bytes()); + hasher.update(cache_target().as_bytes()); + hasher.update(build_fingerprint().to_le_bytes()); + // The compiled object resolves `__quantum__rt__*` / `selene_*` symbols at + // runtime from the QIS FFI shim and the Selene shim, which are SELECTED at + // runtime (library search order + the `PECOS_SELENE_SHIM_PATH` override) + // and may differ from the build-embedded libraries that the executable + // fingerprint captures. Fold each selected library's identity (path + + // last-modified time) into the key so swapping a shim invalidates a cached + // object that was compiled against the old one. + for lib in [ + Self::pinned_qis_ffi_lib_path().ok(), + Self::pinned_shim_lib_path(), + ] + .into_iter() + .flatten() + { + hasher.update(lib.to_string_lossy().as_bytes()); + let mtime_nanos = std::fs::metadata(&lib) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos()); + if let Some(nanos) = mtime_nanos { + hasher.update(nanos.to_le_bytes()); + } + } + let digest = hasher.finalize(); + let mut content_hash = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(content_hash, "{byte:02x}"); + } // Check if we already have a compiled library for this content let compiled_cache = COMPILED_PROGRAM_CACHE @@ -1227,7 +1413,7 @@ impl QisHeliosInterface { if let Some(cached_path) = cache_guard.get(&content_hash) { debug!( - "Using cached compiled library for content hash {content_hash:016x}: {}", + "Using cached compiled library for content hash {content_hash}: {}", cached_path.display() ); // Verify the file still exists (might have been cleaned up) @@ -1257,8 +1443,7 @@ impl QisHeliosInterface { } else { ".so" }; - let persistent_cache_path = - cache_dir.join(format!("program_{content_hash:016x}{lib_suffix}")); + let persistent_cache_path = cache_dir.join(format!("program_{content_hash}{lib_suffix}")); if persistent_cache_path.exists() { debug!( @@ -1274,7 +1459,7 @@ impl QisHeliosInterface { std::sync::Mutex::new(std::collections::BTreeMap::new()) }); if let Ok(mut cache_guard) = compiled_cache.lock() { - cache_guard.insert(content_hash, persistent_cache_path.clone()); + cache_guard.insert(content_hash.clone(), persistent_cache_path.clone()); } } self.executable_path = Some(persistent_cache_path.clone()); @@ -1306,7 +1491,7 @@ impl QisHeliosInterface { let compiled_cache = COMPILED_PROGRAM_CACHE .get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); if let Ok(mut cache_guard) = compiled_cache.lock() { - cache_guard.insert(content_hash, persistent_cache_path.clone()); + cache_guard.insert(content_hash.clone(), persistent_cache_path.clone()); } self.executable_path = Some(persistent_cache_path.clone()); info!( @@ -1334,7 +1519,7 @@ impl QisHeliosInterface { let compiled_cache = COMPILED_PROGRAM_CACHE .get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); if let Ok(mut cache_guard) = compiled_cache.lock() { - cache_guard.insert(content_hash, persistent_cache_path.clone()); + cache_guard.insert(content_hash.clone(), persistent_cache_path.clone()); } self.executable_path = Some(persistent_cache_path.clone()); info!( @@ -1556,26 +1741,9 @@ entry: so_path_for_clang.display() ); - // Build clang command with platform-specific flags - // Try to find clang: first check LLVM_SYS_140_PREFIX, then fall back to PATH - let clang_cmd_path = std::env::var("LLVM_SYS_140_PREFIX") - .ok() - .and_then(|prefix| { - let mut path = PathBuf::from(prefix); - path.push("bin"); - path.push(if cfg!(windows) { "clang.exe" } else { "clang" }); - if path.exists() { - debug!("Using clang from LLVM_SYS_140_PREFIX: {}", path.display()); - Some(path) - } else { - None - } - }) - .unwrap_or_else(|| { - debug!("Using clang from PATH"); - PathBuf::from("clang") - }); - + // Use the same LLVM tool resolver as llvm-as/llvm-link so runtime linking + // honors the build-time LLVM prefix embedded in Python wheels. + let clang_cmd_path = find_llvm_tool("clang"); let mut clang_cmd = Command::new(&clang_cmd_path); // On Windows, we need to be more careful with paths and flags @@ -1596,8 +1764,10 @@ entry: ) })?; - // Find the pecos_qis_ffi.dll.lib import library - let pecos_qis_lib_path = Self::find_pecos_qis_lib()?; + // Find the pecos_qis_ffi.dll.lib import library (pinned, so the link + // import library matches the FFI library hashed in the cache key). + let pecos_qis_lib_path = + Self::pinned_qis_ffi_lib_path().map_err(InterfaceError::LoadError)?; let qis_ffi_import_lib = pecos_qis_lib_path.with_extension("dll.lib"); if !qis_ffi_import_lib.exists() { @@ -1743,14 +1913,41 @@ entry: let compiled_cache = COMPILED_PROGRAM_CACHE .get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); if let Ok(mut cache_guard) = compiled_cache.lock() { - cache_guard.insert(content_hash, so_path.clone()); + cache_guard.insert(content_hash.clone(), so_path.clone()); debug!( - "Cached compiled library for content hash {content_hash:016x}: {}", + "Cached compiled library for content hash {content_hash}: {}", so_path.display() ); } } + // Write an auditable manifest next to the compiled object so the shared + // cache directory can be inspected and each library traced back to the + // exact program/format/build/toolchain that produced it. Best-effort: + // a missing or stale manifest never affects correctness (the filename is + // the content+build digest), so a write failure is only logged. + { + let manifest = CacheManifest { + digest: content_hash.clone(), + format: self.format.cache_tag().to_string(), + build_fingerprint: build_fingerprint(), + pecos_qis_version: env!("CARGO_PKG_VERSION").to_string(), + target: cache_target(), + }; + let manifest_path = so_path.with_extension("manifest"); + match serde_json::to_string_pretty(&manifest) { + Ok(json) => { + if let Err(e) = std::fs::write(&manifest_path, json) { + debug!( + "Failed to write cache manifest {}: {e}", + manifest_path.display() + ); + } + } + Err(e) => debug!("Failed to serialize cache manifest: {e}"), + } + } + // Load the program library into the global cache. // This avoids repeated library load/unload cycles which cause instability on macOS. debug!("Loading program library into global cache..."); @@ -2173,7 +2370,7 @@ impl QisInterface for QisHeliosInterface { } fn get_qis_ffi_lib_path(&self) -> Option { - Self::find_pecos_qis_lib().ok() + Self::pinned_qis_ffi_lib_path().ok() } fn get_execution_context_ptr(&self) -> Option<*mut std::ffi::c_void> { diff --git a/crates/pecos-qis/src/lib.rs b/crates/pecos-qis/src/lib.rs index 44478f886..76338a0fd 100644 --- a/crates/pecos-qis/src/lib.rs +++ b/crates/pecos-qis/src/lib.rs @@ -34,7 +34,7 @@ //! //! # LLVM Setup //! -//! This crate requires LLVM 14 for QIR (Quantum Intermediate Representation) support. +//! This crate requires LLVM 21.1 for QIR (Quantum Intermediate Representation) support. //! //! If the build fails, run: //! diff --git a/crates/pecos-qis/src/prelude.rs b/crates/pecos-qis/src/prelude.rs index 05adeebe0..81cc6f587 100644 --- a/crates/pecos-qis/src/prelude.rs +++ b/crates/pecos-qis/src/prelude.rs @@ -16,6 +16,10 @@ pub use crate::runtime::{QisRuntime, RuntimeError}; pub use crate::ccengine::QisEngine; pub use crate::engine_builder::{QisEngineBuilder, qis_engine}; +// QSystem platform selector for HUGR lowering (re-exported for `.platform(...)`) +#[cfg(feature = "hugr")] +pub use pecos_hugr_qis::QSystemPlatform; + // Program types pub use crate::program::{ InterfaceChoice, IntoQisInterface, ProgramType, QisEngineProgram, QisInterfaceBuilder, diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index 305add2e1..bb39af454 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -19,6 +19,21 @@ pub enum ProgramFormat { QisBitcode, } +impl ProgramFormat { + /// A stable identifier for persistent-cache keys and manifests, independent + /// of the `Debug` representation (which would change if a variant is renamed, + /// silently invalidating or, worse, colliding cached objects). + #[must_use] + pub fn cache_tag(self) -> &'static str { + match self { + Self::LlvmIrText => "llvm-ir-text", + Self::LlvmBitcode => "llvm-bitcode", + Self::HugrBytes => "hugr-bytes", + Self::QisBitcode => "qis-bitcode", + } + } +} + /// Error type for interface operations /// /// This is kept minimal to avoid circular dependencies with pecos-core. @@ -273,6 +288,17 @@ pub trait DynamicSyncHandle: Send + Sync { fn get_named_results( &self, ) -> Result>, InterfaceError>; + + /// Get named result provenance from the execution context. + /// + /// Returns one record per `result(...)` output call, including the runtime + /// measurement result IDs read to produce that output. + /// + /// # Errors + /// Returns an error if the FFI call fails or JSON parsing fails. + fn get_named_result_traces( + &self, + ) -> Result, InterfaceError>; } /// Box type for interface implementations diff --git a/crates/pecos-qis/src/runtime.rs b/crates/pecos-qis/src/runtime.rs index 2f5b30330..20d4ef664 100644 --- a/crates/pecos-qis/src/runtime.rs +++ b/crates/pecos-qis/src/runtime.rs @@ -11,7 +11,7 @@ //! doesn't perform quantum simulation but manages program execution flow. use log::trace; -use pecos_qis_ffi_types::{OperationCollector, QuantumOp}; +use pecos_qis_ffi_types::{LoweredQuantumOp, Operation, OperationCollector, QuantumOp}; use std::collections::BTreeMap; /// Result type for runtime operations @@ -201,6 +201,14 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { /// Get the number of qubits used by the program fn num_qubits(&self) -> usize; + /// Provide an external qubit-count hint before runtime initialization. + /// + /// Dynamic programs discover qubits while the program runs, but some + /// runtime plugins need the total device size during initialization. + fn set_num_qubits(&mut self, _num_qubits: usize) { + // Default implementation does nothing. + } + /// Set the maximum number of operations to batch /// /// This allows tuning the trade-off between runtime overhead and @@ -210,6 +218,46 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { let _ = size; } + /// Whether this runtime can lower freshly collected program operations. + /// + /// Dynamic QIS execution yields PECOS `Operation`s from the running program. + /// Most runtimes leave those operations for the engine to lower directly. + /// Selene runtime plugins opt in here so the operation stream flows through + /// the runtime scheduler before it reaches the PECOS quantum/noise stack. + fn supports_operation_lowering(&self) -> bool { + false + } + + /// Lower freshly collected program operations through the runtime. + /// + /// Implementations that return `true` from `supports_operation_lowering` + /// should accept the given operations, drain any runtime-ready scheduled + /// operations, and return them as PECOS quantum operations. + /// + /// # Errors + /// Returns an error if the runtime cannot accept or lower the operations. + fn lower_operations(&mut self, _operations: &[Operation]) -> Result> { + Err(RuntimeError::ExecutionError( + "runtime does not support operation lowering".to_string(), + )) + } + + /// Lower freshly collected program operations through the runtime with provenance. + /// + /// Runtimes that can preserve source/scheduler metadata should override this + /// method. The default preserves the existing `lower_operations` behavior and + /// attaches empty metadata to every lowered operation. + /// + /// # Errors + /// Returns an error if the runtime cannot accept or lower the operations. + fn lower_operations_with_metadata( + &mut self, + operations: &[Operation], + ) -> Result> { + self.lower_operations(operations) + .map(|ops| ops.into_iter().map(LoweredQuantumOp::from).collect()) + } + /// Check if the runtime needs to re-execute with known measurements /// /// This is set to true after measurements are provided for programs diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 913c37c9a..c33d7a80f 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -5,13 +5,190 @@ use crate::runtime::{ClassicalState, QisRuntime, Result, RuntimeError, Shot}; use log::{debug, trace}; -use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; -use std::collections::BTreeMap; -use std::ffi::c_void; +use pecos_qis_ffi_types::{ + LoweredQuantumOp, Operation, OperationCollector, QuantumOp, TraceMetadata, +}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::ffi::{CString, c_void}; use std::mem::ManuallyDrop; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; +type RuntimeInstance = *mut c_void; +type RuntimeGetOperationInstance = *mut c_void; + +#[derive(Debug, Clone)] +enum RuntimeScheduledOp { + Rxy { + qubit_id: u64, + theta: f64, + phi: f64, + }, + Rz { + qubit_id: u64, + theta: f64, + }, + Rzz { + qubit_id_1: u64, + qubit_id_2: u64, + theta: f64, + }, + Measure { + qubit_id: u64, + result_id: u64, + }, + MeasureLeaked { + qubit_id: u64, + result_id: u64, + }, + Reset { + qubit_id: u64, + }, + Custom, +} + +#[derive(Debug, Default)] +struct RuntimeOperationBatch { + start_time_nanos: u64, + duration_nanos: u64, + invoked: bool, + operations: Vec, +} + +impl RuntimeOperationBatch { + fn end_time_nanos(&self) -> u64 { + self.start_time_nanos.saturating_add(self.duration_nanos) + } +} + +#[derive(Debug, Clone)] +struct SourceTraceMetadata { + op: QuantumOp, + metadata: TraceMetadata, +} + +// `_fn` postfix is intentional: this is a `#[repr(C)]` vtable of Selene runtime +// callback function pointers, where the suffix marks each field as a function +// pointer. Bare names like `reset`/`custom`/`measure` would be ambiguous here. +#[allow(clippy::struct_field_names)] +#[repr(C)] +struct SeleneRuntimeGetOperationInterface { + rzz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64, f64), + rxy_fn: extern "C" fn(RuntimeGetOperationInstance, u64, f64, f64), + rz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, f64), + measure_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), + measure_leaked_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), + reset_fn: extern "C" fn(RuntimeGetOperationInstance, u64), + custom_fn: extern "C" fn(RuntimeGetOperationInstance, usize, *const c_void, usize), + set_batch_time_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), +} + +extern "C" fn runtime_batch_rxy( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + theta: f64, + phi: f64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Rxy { + qubit_id, + theta, + phi, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_rz(instance: RuntimeGetOperationInstance, qubit_id: u64, theta: f64) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch + .operations + .push(RuntimeScheduledOp::Rz { qubit_id, theta }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_rzz( + instance: RuntimeGetOperationInstance, + qubit_id_1: u64, + qubit_id_2: u64, + theta: f64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Rzz { + qubit_id_1, + qubit_id_2, + theta, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_measure( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + result_id: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Measure { + qubit_id, + result_id, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_measure_leaked( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + result_id: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::MeasureLeaked { + qubit_id, + result_id, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_reset(instance: RuntimeGetOperationInstance, qubit_id: u64) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch + .operations + .push(RuntimeScheduledOp::Reset { qubit_id }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_custom( + instance: RuntimeGetOperationInstance, + _tag: usize, + _data: *const c_void, + _data_len: usize, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Custom); + batch.invoked = true; +} + +extern "C" fn runtime_batch_set_time( + instance: RuntimeGetOperationInstance, + start_time_nanos: u64, + duration_nanos: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.start_time_nanos = start_time_nanos; + batch.duration_nanos = duration_nanos; + batch.invoked = true; +} + +static RUNTIME_OPERATION_CALLBACKS: SeleneRuntimeGetOperationInterface = + SeleneRuntimeGetOperationInterface { + rzz_fn: runtime_batch_rzz, + rxy_fn: runtime_batch_rxy, + rz_fn: runtime_batch_rz, + measure_fn: runtime_batch_measure, + measure_leaked_fn: runtime_batch_measure_leaked, + reset_fn: runtime_batch_reset, + custom_fn: runtime_batch_custom, + set_batch_time_fn: runtime_batch_set_time, + }; + /// Selene runtime implementation /// /// The `library` field is wrapped in `ManuallyDrop` to prevent calling `dlclose()` @@ -22,6 +199,12 @@ pub struct SeleneRuntime { /// Path to the Selene .so file plugin_path: String, + /// Runtime-plugin init arguments passed to `selene_runtime_init`. + init_args: Vec, + + /// Additional dynamic library search directories needed by the plugin. + library_search_dirs: Vec, + /// Loaded library (if any) /// Wrapped in `ManuallyDrop` to prevent `dlclose()` during process exit. #[allow(dead_code)] @@ -31,6 +214,9 @@ pub struct SeleneRuntime { #[allow(dead_code)] instance: Option<*mut c_void>, + /// Number of qubits the current runtime instance was initialized with. + initialized_num_qubits: Option, + /// Current classical state state: ClassicalState, @@ -43,6 +229,21 @@ pub struct SeleneRuntime { /// Number of qubits num_qubits: usize, + /// Explicit physical runtime capacity requested by the caller. + /// + /// Some generated programs use sparse, monotonically increasing logical + /// handles while guaranteeing a smaller maximum number of live physical + /// slots. In those cases the `.qubits(...)` hint is the runtime capacity; + /// if the program actually exceeds it, plugin qalloc fails loudly. + num_qubits_hint: Option, + + /// Whether the loaded operation stream uses explicit qalloc/qfree records. + /// + /// In this mode program qubit IDs are logical handles, not dense physical + /// runtime slots. Runtime plugin capacity must therefore follow the maximum + /// simultaneously-live allocation count instead of `max(program_id) + 1`. + uses_explicit_qubit_allocation: bool, + /// Number of allocated result slots num_results: usize, @@ -58,6 +259,21 @@ pub struct SeleneRuntime { /// Track measurement result IDs that have been seen but not yet resolved pending_measurements: Vec, + + /// Program qubit handles mapped onto runtime qubit handles returned by qalloc. + program_to_runtime_qubits: BTreeMap, + + /// Program result IDs mapped onto runtime future IDs returned by measure. + program_to_runtime_results: BTreeMap, + + /// Reverse lookup for measurement operations emitted by the runtime plugin. + runtime_to_program_results: BTreeMap, + + /// End timestamp of the last scheduled physical operation per runtime qubit. + last_gate_time_end_nanos: Vec, + + /// Shot metadata waiting for a lazily loaded runtime plugin. + pending_shot_start: Option<(u64, Option)>, } // SAFETY: SeleneRuntime owns its instance pointer exclusively. @@ -72,20 +288,46 @@ impl SeleneRuntime { pub fn new(plugin_path: impl AsRef) -> Self { Self { plugin_path: plugin_path.as_ref().to_string_lossy().to_string(), + init_args: Vec::new(), + library_search_dirs: Vec::new(), library: None, instance: None, + initialized_num_qubits: None, state: ClassicalState::default(), operations_buffer: Vec::new(), batch_size: 100, num_qubits: 0, + num_qubits_hint: None, + uses_explicit_qubit_allocation: false, num_results: 0, interface: None, current_op_index: 0, needs_reexecution: false, pending_measurements: Vec::new(), + program_to_runtime_qubits: BTreeMap::new(), + program_to_runtime_results: BTreeMap::new(), + runtime_to_program_results: BTreeMap::new(), + last_gate_time_end_nanos: Vec::new(), + pending_shot_start: None, } } + /// Create a runtime from the generic Selene runtime-plugin shape. + /// + /// `init_args` are passed directly to the plugin's `selene_runtime_init` + /// argc/argv pair. `library_search_dirs` are prepended to the platform + /// dynamic-library search path before loading the plugin. + pub fn with_plugin_config( + plugin_path: impl AsRef, + init_args: Vec, + library_search_dirs: Vec, + ) -> Self { + let mut runtime = Self::new(plugin_path); + runtime.init_args = init_args; + runtime.library_search_dirs = library_search_dirs; + runtime + } + /// Check if this runtime needs re-execution with known measurements /// /// This is set to true after measurements are provided for programs @@ -108,17 +350,14 @@ impl SeleneRuntime { self.interface.as_ref().map_or(0, |i| i.operations.len()) ); - // Update qubit and result counts from new execution - self.num_qubits = operations - .allocated_qubits - .iter() - .max() - .map_or(0, |&q| q + 1); - self.num_results = operations - .allocated_results - .iter() - .max() - .map_or(0, |&r| r + 1); + // Update capacities from explicit allocation records when available, + // otherwise fall back to direct program handles used by older QIR/LLVM + // examples. + let (num_qubits, num_results) = collector_capacity(&operations); + self.num_qubits = num_qubits; + self.num_results = num_results; + self.uses_explicit_qubit_allocation = + has_explicit_qubit_allocations(&operations.operations); self.interface = Some(operations); self.current_op_index = 0; @@ -132,9 +371,15 @@ impl SeleneRuntime { return Ok(()); } + self.apply_library_search_dirs()?; + let plugin_num_qubits = self.plugin_num_qubits(); + debug!( - "Loading Selene plugin from {} with {} qubits and {} results", - self.plugin_path, self.num_qubits, self.num_results + "Loading Selene plugin from {} with {} qubits, {} results, and {} init args", + self.plugin_path, + plugin_num_qubits, + self.num_results, + self.init_args.len() ); unsafe { @@ -150,13 +395,31 @@ impl SeleneRuntime { .get(b"selene_runtime_init") .map_err(|e| RuntimeError::FfiError(format!("Missing init function: {e}")))?; + let c_args = self + .init_args + .iter() + .map(|arg| { + CString::new(arg.as_str()).map_err(|_| { + RuntimeError::FfiError(format!( + "Selene runtime init argument contains NUL byte: {arg:?}" + )) + }) + }) + .collect::>>()?; + let arg_ptrs = c_args.iter().map(|arg| arg.as_ptr()).collect::>(); + let argv = if arg_ptrs.is_empty() { + std::ptr::null() + } else { + arg_ptrs.as_ptr() + }; + let mut instance: *mut c_void = std::ptr::null_mut(); let errno = init_fn( &raw mut instance, - self.num_qubits as u64, - 0, // start time - 0, // argc - std::ptr::null(), // argv + plugin_num_qubits as u64, + 0, // start time + u32::try_from(arg_ptrs.len()).expect("plugin argument count fits in u32"), + argv, ); if errno != 0 { @@ -167,6 +430,120 @@ impl SeleneRuntime { self.library = Some(ManuallyDrop::new(lib)); self.instance = Some(instance); + self.initialized_num_qubits = Some(plugin_num_qubits); + } + + self.apply_pending_shot_start()?; + Ok(()) + } + + fn ensure_plugin_capacity(&mut self) -> Result<()> { + let Some(initialized_num_qubits) = self.initialized_num_qubits else { + return Ok(()); + }; + + let plugin_num_qubits = self.plugin_num_qubits(); + if plugin_num_qubits <= initialized_num_qubits { + return Ok(()); + } + + debug!( + "Reinitializing Selene plugin capacity from {initialized_num_qubits} to {plugin_num_qubits} qubits" + ); + self.reset_plugin_instance()?; + self.load_plugin() + } + + fn plugin_num_qubits(&self) -> usize { + // An explicit hint is authoritative and caps the plugin capacity (a + // program that exceeds it fails loudly at qalloc by design). The bogus + // "0 inferred before execution" case is handled upstream in SimBuilder, + // which no longer freezes that 0 as a hint. + self.num_qubits_hint.unwrap_or(self.num_qubits) + } + + fn reset_plugin_instance(&mut self) -> Result<()> { + if let Some(lib) = &self.library + && let Some(instance) = self.instance + { + unsafe { + if let Ok(exit_fn) = + lib.get:: i32>(b"selene_runtime_exit") + { + let errno = exit_fn(instance); + if errno != 0 { + return Err(RuntimeError::ExecutionError(format!( + "Selene runtime exit failed with errno {errno}" + ))); + } + } + } + } + + self.instance = None; + self.library = None; + self.initialized_num_qubits = None; + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); + Ok(()) + } + + fn apply_pending_shot_start(&mut self) -> Result<()> { + let Some((shot_id, seed)) = self.pending_shot_start else { + return Ok(()); + }; + let Some(lib) = &self.library else { + return Ok(()); + }; + let Some(instance) = self.instance else { + return Ok(()); + }; + + unsafe { + if let Ok(shot_start_fn) = lib + .get:: i32>( + b"selene_runtime_shot_start", + ) + { + let errno = shot_start_fn(instance, shot_id, seed.unwrap_or(0)); + if errno != 0 { + return Err(RuntimeError::ExecutionError(format!( + "Shot start failed with errno {errno}" + ))); + } + } + } + + self.pending_shot_start = None; + Ok(()) + } + + fn apply_library_search_dirs(&self) -> Result<()> { + if self.library_search_dirs.is_empty() { + return Ok(()); + } + + let env_key = if cfg!(target_os = "windows") { + "PATH" + } else if cfg!(target_os = "macos") { + "DYLD_LIBRARY_PATH" + } else { + "LD_LIBRARY_PATH" + }; + + let existing = std::env::var_os(env_key).unwrap_or_default(); + let mut paths = self.library_search_dirs.clone(); + paths.extend(std::env::split_paths(&existing)); + let joined = std::env::join_paths(paths).map_err(|e| { + RuntimeError::FfiError(format!("Invalid Selene runtime library search path: {e}")) + })?; + + // SAFETY: This mirrors Selene's plugin runtime environment setup. The + // mutation happens immediately before loading the selected runtime. + unsafe { + std::env::set_var(env_key, joined); } Ok(()) @@ -214,7 +591,6 @@ impl SeleneRuntime { } Operation::AllocateQubit { id } => { trace!("Allocating qubit {id}"); - self.num_qubits = self.num_qubits.max(id + 1); self.current_op_index += 1; } Operation::AllocateResult { id } => { @@ -238,6 +614,10 @@ impl SeleneRuntime { // The actual result mapping is handled by the runtime's results collection self.current_op_index += 1; } + Operation::TraceMetadata { .. } => { + trace!("Trace metadata encountered"); + self.current_op_index += 1; + } Operation::Barrier => { trace!("Barrier encountered"); // Barriers don't produce quantum ops but can break batches @@ -260,214 +640,1330 @@ impl SeleneRuntime { Ok(Some(self.operations_buffer.clone())) } } -} -impl Clone for SeleneRuntime { - fn clone(&self) -> Self { - // For now, create a new instance with the same plugin path - // The library itself can't be cloned, so we'll reload if needed - Self { - plugin_path: self.plugin_path.clone(), - library: None, // Will be reloaded on demand - instance: None, // Will be recreated on demand - state: self.state.clone(), - operations_buffer: self.operations_buffer.clone(), - batch_size: self.batch_size, - num_qubits: self.num_qubits, - num_results: self.num_results, - interface: self.interface.clone(), - current_op_index: self.current_op_index, - needs_reexecution: self.needs_reexecution, - pending_measurements: self.pending_measurements.clone(), + fn runtime_qubit_for_program(&mut self, program_qubit: usize) -> Result { + if let Some(&runtime_qubit) = self.program_to_runtime_qubits.get(&program_qubit) { + return Ok(runtime_qubit); } - } -} -impl QisRuntime for SeleneRuntime { - fn load_interface(&mut self, interface: OperationCollector) -> Result<()> { - debug!( - "Loading QIS interface with {} operations", - interface.operations.len() - ); + self.load_plugin()?; + let runtime_qubit = self.runtime_qalloc()?; + self.program_to_runtime_qubits + .insert(program_qubit, runtime_qubit); + if !self.uses_explicit_qubit_allocation { + self.num_qubits = self.num_qubits.max(program_qubit + 1); + } + Ok(runtime_qubit) + } - // Count qubits and results - self.num_qubits = interface - .allocated_qubits - .iter() - .max() - .map_or(0, |&q| q + 1); - self.num_results = interface - .allocated_results - .iter() - .max() - .map_or(0, |&r| r + 1); + fn runtime_qalloc(&self) -> Result { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; - debug!( - "Interface has {} qubits and {} result slots", - self.num_qubits, self.num_results - ); + unsafe { + let qalloc_fn = lib + .get:: i32>( + b"selene_runtime_qalloc", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing qalloc function: {e}")))?; + let mut runtime_qubit = 0; + let errno = qalloc_fn(instance, &raw mut runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "qalloc failed with errno {errno}" + ))); + } + if runtime_qubit == u64::MAX { + return Err(RuntimeError::ExecutionError( + "Selene runtime failed to allocate a qubit".to_string(), + )); + } + Ok(runtime_qubit) + } + } - self.interface = Some(interface); - self.current_op_index = 0; - self.needs_reexecution = false; - self.pending_measurements.clear(); + fn runtime_qfree(&self, runtime_qubit: u64) -> Result<()> { + let Some(lib) = &self.library else { + return Ok(()); + }; + let Some(instance) = self.instance else { + return Ok(()); + }; - // Don't load the plugin yet - defer until actually needed - // This allows creating and testing the runtime without a real .so file + unsafe { + let qfree_fn = lib + .get:: i32>(b"selene_runtime_qfree") + .map_err(|e| RuntimeError::FfiError(format!("Missing qfree function: {e}")))?; + let errno = qfree_fn(instance, runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "qfree failed with errno {errno}" + ))); + } + } Ok(()) } - fn execute_until_quantum(&mut self) -> Result>> { - // For now, we'll use the simple approach of processing from the interface - // In a full implementation, we'd call into the Selene runtime's - // get_next_operations function - self.process_interface_ops() - } + fn call_runtime_rxy(&self, runtime_qubit: u64, theta: f64, phi: f64) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; - fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()> { - debug!( - "Received {} measurement results, num_results={}, allocated_results={:?}", - measurements.len(), - self.num_results, - self.interface.as_ref().map(|i| &i.allocated_results) - ); + unsafe { + let rxy_fn = lib + .get:: i32>( + b"selene_runtime_rxy_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rxy function: {e}")))?; + let errno = rxy_fn(instance, runtime_qubit, theta, phi); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rxy failed with errno {errno}" + ))); + } + } - // Store measurements in classical state - for (result_id, value) in &measurements { - trace!( - "Measurement result {} = {} (num_results={})", - result_id, value, self.num_results - ); - self.state.measurements.insert(*result_id, *value); + Ok(()) + } - // For Selene runtime: Only pass measurements that were explicitly allocated - // The Selene runtime doesn't support dynamic result allocation, so we must - // check if this result was known at compile time - if let Some(interface) = &mut self.interface { - if interface.allocated_results.contains(result_id) { - // This result was explicitly allocated, try to pass to Selene runtime - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(set_result_fn) = - lib.get:: i32>( - b"selene_runtime_set_bool_result", - ) - { - let errno = set_result_fn(instance, *result_id as u64, *value); - if errno != 0 { - // Unexpected error - log it at trace level since this is normal - // for programs that don't explicitly allocate all result slots - log::trace!( - "Selene runtime returned error {errno} for result {result_id}" - ); - } - } - } - } - } else { - // Result wasn't explicitly allocated - this is normal for LLVM programs - // that use implicit result IDs in measurements - log::trace!( - "Measurement result {result_id} was not explicitly allocated, storing locally only" - ); - } + fn call_runtime_rz(&self, runtime_qubit: u64, theta: f64) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; - // Update the interface with the measurement result - interface.store_result(*result_id, *value); - } else { - // No interface loaded - just store locally - log::trace!("No interface loaded, storing measurement {result_id} locally"); + unsafe { + let rz_fn = lib + .get:: i32>( + b"selene_runtime_rz_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rz function: {e}")))?; + let errno = rz_fn(instance, runtime_qubit, theta); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rz failed with errno {errno}" + ))); } } - // Check if there are remaining operations that might depend on these measurements - // If so, we need to re-execute the program with the known measurement values - // so that conditionals can evaluate correctly - if let Some(interface) = &self.interface { - let remaining_ops = interface - .operations - .len() - .saturating_sub(self.current_op_index); - if remaining_ops > 0 && !measurements.is_empty() { - debug!( - "Setting needs_reexecution=true: {} ops remaining after {} measurements", - remaining_ops, - measurements.len() - ); - self.needs_reexecution = true; + Ok(()) + } + + fn call_runtime_rzz( + &self, + runtime_qubit_1: u64, + runtime_qubit_2: u64, + theta: f64, + ) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let rzz_fn = lib + .get:: i32>( + b"selene_runtime_rzz_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rzz function: {e}")))?; + let errno = rzz_fn(instance, runtime_qubit_1, runtime_qubit_2, theta); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rzz failed with errno {errno}" + ))); } } Ok(()) } - fn get_classical_state(&self) -> &ClassicalState { - &self.state - } + fn call_runtime_reset(&self, runtime_qubit: u64) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; - fn get_classical_state_mut(&mut self) -> &mut ClassicalState { - &mut self.state - } + unsafe { + let reset_fn = lib + .get:: i32>(b"selene_runtime_reset") + .map_err(|e| RuntimeError::FfiError(format!("Missing reset function: {e}")))?; + let errno = reset_fn(instance, runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "reset failed with errno {errno}" + ))); + } + } - fn is_complete(&self) -> bool { - self.interface - .as_ref() - .is_none_or(|i| self.current_op_index >= i.operations.len()) + Ok(()) } - fn num_qubits(&self) -> usize { - self.num_qubits - } + fn call_runtime_measure(&mut self, runtime_qubit: u64, program_result: usize) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + let runtime_result = unsafe { + let measure_fn = lib + .get:: i32>( + b"selene_runtime_measure", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing measure function: {e}")))?; + let mut runtime_result = 0; + let errno = measure_fn(instance, runtime_qubit, &raw mut runtime_result); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "measure failed with errno {errno}" + ))); + } + runtime_result + }; - fn set_batch_size(&mut self, size: usize) { - self.batch_size = size; + self.program_to_runtime_results + .insert(program_result, runtime_result); + self.runtime_to_program_results + .insert(runtime_result, program_result); + self.force_runtime_result(runtime_result) } - fn needs_reexecution(&self) -> bool { - self.needs_reexecution + fn force_runtime_result(&self, runtime_result: u64) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let force_fn = lib + .get:: i32>( + b"selene_runtime_force_result", + ) + .map_err(|e| { + RuntimeError::FfiError(format!("Missing force_result function: {e}")) + })?; + let errno = force_fn(instance, runtime_result); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "force_result failed with errno {errno}" + ))); + } + } + + Ok(()) } - fn clear_reexecution_flag(&mut self) { - self.needs_reexecution = false; + fn call_runtime_global_barrier(&self, sleep_time: u64) -> Result { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let Ok(global_barrier_fn) = lib + .get:: i32>( + b"selene_runtime_global_barrier", + ) + else { + return Ok(false); + }; + let errno = global_barrier_fn(instance, sleep_time); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "global_barrier failed with errno {errno}" + ))); + } + } + + Ok(true) } - fn reload_operations(&mut self, operations: OperationCollector) { - SeleneRuntime::reload_operations(self, operations); + fn lower_runtime_barrier(&mut self) -> Result> { + self.load_plugin()?; + + // Runtime-native barriers keep scheduler-specific ordering decisions + // inside the plugin. Falling back to a drain preserves compatibility + // with older plugins that do not expose Selene barrier symbols. + if self.call_runtime_global_barrier(0)? { + return Ok(Vec::new()); + } + + self.drain_runtime_operations() } - fn shot_start(&mut self, shot_id: u64, seed: Option) -> Result<()> { - // Try to load the plugin if not already loaded - if self.library.is_none() && std::path::Path::new(&self.plugin_path).exists() { - self.load_plugin()?; + fn submit_operation_to_runtime( + &mut self, + op: &Operation, + lowered_ops: &mut Vec, + ) -> Result<()> { + match op { + Operation::AllocateQubit { id } => { + let _ = self.runtime_qubit_for_program(*id)?; + } + Operation::AllocateResult { id } => { + self.num_results = self.num_results.max(id + 1); + } + Operation::ReleaseQubit { id } => { + if let Some(runtime_qubit) = self.program_to_runtime_qubits.remove(id) { + self.runtime_qfree(runtime_qubit)?; + } + } + Operation::RecordOutput { .. } + | Operation::TraceMetadata { .. } + | Operation::Barrier => {} + Operation::Quantum(qop) => self.submit_quantum_op_to_runtime(qop, lowered_ops)?, } - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(shot_start_fn) = lib - .get:: i32>( - b"selene_runtime_shot_start", + Ok(()) + } + + fn map_quantum_op_to_runtime_qubits(&mut self, qop: &QuantumOp) -> Result { + let mut map = |qubit: usize| -> Result { + let runtime_qubit = self.runtime_qubit_for_program(qubit)?; + usize::try_from(runtime_qubit).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime qubit id {runtime_qubit} does not fit in usize" + )) + }) + }; + + Ok(match qop { + QuantumOp::H(qubit) => QuantumOp::H(map(*qubit)?), + QuantumOp::X(qubit) => QuantumOp::X(map(*qubit)?), + QuantumOp::Y(qubit) => QuantumOp::Y(map(*qubit)?), + QuantumOp::Z(qubit) => QuantumOp::Z(map(*qubit)?), + QuantumOp::S(qubit) => QuantumOp::S(map(*qubit)?), + QuantumOp::Sdg(qubit) => QuantumOp::Sdg(map(*qubit)?), + QuantumOp::T(qubit) => QuantumOp::T(map(*qubit)?), + QuantumOp::Tdg(qubit) => QuantumOp::Tdg(map(*qubit)?), + QuantumOp::RX(theta, qubit) => QuantumOp::RX(*theta, map(*qubit)?), + QuantumOp::RY(theta, qubit) => QuantumOp::RY(*theta, map(*qubit)?), + QuantumOp::RZ(theta, qubit) => QuantumOp::RZ(*theta, map(*qubit)?), + QuantumOp::RXY(theta, phi, qubit) => QuantumOp::RXY(*theta, *phi, map(*qubit)?), + QuantumOp::Idle(duration, qubit) => QuantumOp::Idle(*duration, map(*qubit)?), + QuantumOp::CX(control, target) => QuantumOp::CX(map(*control)?, map(*target)?), + QuantumOp::CY(control, target) => QuantumOp::CY(map(*control)?, map(*target)?), + QuantumOp::CZ(control, target) => QuantumOp::CZ(map(*control)?, map(*target)?), + QuantumOp::CH(control, target) => QuantumOp::CH(map(*control)?, map(*target)?), + QuantumOp::CRZ(theta, control, target) => { + QuantumOp::CRZ(*theta, map(*control)?, map(*target)?) + } + QuantumOp::CCX(control_1, control_2, target) => { + QuantumOp::CCX(map(*control_1)?, map(*control_2)?, map(*target)?) + } + QuantumOp::ZZ(qubit_1, qubit_2) => QuantumOp::ZZ(map(*qubit_1)?, map(*qubit_2)?), + QuantumOp::RZZ(theta, qubit_1, qubit_2) => { + QuantumOp::RZZ(*theta, map(*qubit_1)?, map(*qubit_2)?) + } + QuantumOp::Measure(qubit, result_id) => QuantumOp::Measure(map(*qubit)?, *result_id), + QuantumOp::Reset(qubit) => QuantumOp::Reset(map(*qubit)?), + }) + } + + fn submit_quantum_op_to_runtime( + &mut self, + qop: &QuantumOp, + lowered_ops: &mut Vec, + ) -> Result<()> { + match qop { + QuantumOp::RXY(theta, phi, qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_rxy(runtime_qubit, *theta, *phi)?; + } + QuantumOp::RZ(theta, qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_rz(runtime_qubit, *theta)?; + } + QuantumOp::RZZ(theta, qubit_1, qubit_2) => { + let runtime_qubit_1 = self.runtime_qubit_for_program(*qubit_1)?; + let runtime_qubit_2 = self.runtime_qubit_for_program(*qubit_2)?; + self.call_runtime_rzz(runtime_qubit_1, runtime_qubit_2, *theta)?; + } + QuantumOp::Measure(qubit, result_id) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_measure(runtime_qubit, *result_id)?; + self.program_to_runtime_qubits.remove(qubit); + self.runtime_qfree(runtime_qubit)?; + } + QuantumOp::Reset(qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_reset(runtime_qubit)?; + } + _ => { + lowered_ops.extend(self.drain_runtime_operations()?); + lowered_ops.push(self.map_quantum_op_to_runtime_qubits(qop)?); + } + } + + Ok(()) + } + + fn drain_runtime_operations(&mut self) -> Result> { + self.load_plugin()?; + let mut lowered_ops = Vec::new(); + + loop { + let mut batch = RuntimeOperationBatch::default(); + let errno = { + let lib = self.library.as_ref().ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not loaded".to_string()) + })?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let get_next_fn = lib + .get:: i32>(b"selene_runtime_get_next_operations") + .map_err(|e| { + RuntimeError::FfiError(format!( + "Missing get_next_operations function: {e}" + )) + })?; + get_next_fn( + instance, + (&raw mut batch).cast::(), + &raw const RUNTIME_OPERATION_CALLBACKS, + ) + } + }; + + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "get_next_operations failed with errno {errno}" + ))); + } + + if !batch.invoked { + break; + } + + lowered_ops.extend(self.convert_runtime_batch(batch)?); + } + + Ok(lowered_ops) + } + + fn push_lowered_ops_with_source_metadata( + lowered_ops: &mut Vec, + ops: Vec, + source_metadata: &mut VecDeque, + ) { + for op in ops { + let metadata = if let Some(source_index) = source_metadata + .iter() + .position(|source| Self::source_trace_metadata_matches_lowered_op(source, &op)) + { + source_metadata + .remove(source_index) + .map(|source| source.metadata) + .unwrap_or_default() + } else { + TraceMetadata::new() + }; + lowered_ops.push(LoweredQuantumOp::new(op, metadata)); + } + } + + fn merge_trace_metadata(target: &mut TraceMetadata, metadata: TraceMetadata) -> Result<()> { + for (key, value) in metadata { + if let Some(existing) = target.get(&key) + && existing != &value + { + return Err(RuntimeError::ExecutionError(format!( + "conflicting trace metadata for key {key:?}: {existing:?} != {value:?}" + ))); + } + target.insert(key, value); + } + Ok(()) + } + + fn take_pending_trace_metadata_for_source_op( + qop: &QuantumOp, + pending_global_metadata: &mut TraceMetadata, + pending_qubit_metadata: &mut BTreeMap, + ) -> Result { + let mut metadata = std::mem::take(pending_global_metadata); + let mut consumed_qubits = Vec::new(); + + for qubit in Self::quantum_op_qubits(qop) { + let Some(pending) = pending_qubit_metadata.get(&qubit) else { + continue; + }; + if !Self::trace_metadata_can_annotate_source_op(pending, qop) { + continue; + } + Self::merge_trace_metadata(&mut metadata, pending.clone())?; + consumed_qubits.push(qubit); + } + + for qubit in consumed_qubits { + pending_qubit_metadata.remove(&qubit); + } + + Ok(metadata) + } + + fn trace_metadata_can_annotate_source_op(metadata: &TraceMetadata, qop: &QuantumOp) -> bool { + let Some(source_gate) = metadata.get("source_gate").map(String::as_str) else { + return true; + }; + match source_gate { + "SZZ" | "SZZDG" => Self::two_qubit_gate_qubits(qop).is_some(), + _ => Self::single_qubit_gate_qubit(qop).is_some(), + } + } + + fn source_trace_metadata_matches_lowered_op( + source: &SourceTraceMetadata, + lowered: &QuantumOp, + ) -> bool { + if source.metadata.contains_key("source_gate") { + return Self::source_gate_metadata_matches_lowered_op(source, lowered); + } + Self::source_op_matches_lowered_op(&source.op, lowered) + } + + fn source_gate_metadata_matches_lowered_op( + source: &SourceTraceMetadata, + lowered: &QuantumOp, + ) -> bool { + let Some(source_gate) = source.metadata.get("source_gate").map(String::as_str) else { + return false; + }; + if matches!(source_gate, "SZZ" | "SZZDG") { + let Some((source_qubit_1, source_qubit_2)) = Self::two_qubit_gate_qubits(&source.op) + else { + return false; + }; + let Some((lowered_qubit_1, lowered_qubit_2)) = Self::two_qubit_gate_qubits(lowered) + else { + return false; + }; + return Self::same_unordered_pair( + source_qubit_1, + source_qubit_2, + lowered_qubit_1, + lowered_qubit_2, + ); + } + let Some(source_qubit) = Self::single_qubit_gate_qubit(&source.op) else { + return false; + }; + let Some(lowered_qubit) = Self::single_qubit_gate_qubit(lowered) else { + return false; + }; + source_qubit == lowered_qubit + } + + fn source_op_matches_lowered_op(source: &QuantumOp, lowered: &QuantumOp) -> bool { + match (source, lowered) { + (QuantumOp::H(source_qubit), QuantumOp::H(lowered_qubit)) + | (QuantumOp::X(source_qubit), QuantumOp::X(lowered_qubit)) + | (QuantumOp::Y(source_qubit), QuantumOp::Y(lowered_qubit)) + | (QuantumOp::Z(source_qubit), QuantumOp::Z(lowered_qubit)) + | (QuantumOp::S(source_qubit), QuantumOp::S(lowered_qubit)) + | (QuantumOp::Sdg(source_qubit), QuantumOp::Sdg(lowered_qubit)) + | (QuantumOp::T(source_qubit), QuantumOp::T(lowered_qubit)) + | (QuantumOp::Tdg(source_qubit), QuantumOp::Tdg(lowered_qubit)) + | (QuantumOp::Reset(source_qubit), QuantumOp::Reset(lowered_qubit)) => { + source_qubit == lowered_qubit + } + ( + QuantumOp::RX(source_theta, source_qubit), + QuantumOp::RX(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::RY(source_theta, source_qubit), + QuantumOp::RY(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::RZ(source_theta, source_qubit), + QuantumOp::RZ(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::Idle(source_theta, source_qubit), + QuantumOp::Idle(lowered_theta, lowered_qubit), + ) => source_qubit == lowered_qubit && Self::same_float(*source_theta, *lowered_theta), + ( + QuantumOp::RXY(source_theta, source_phi, source_qubit), + QuantumOp::RXY(lowered_theta, lowered_phi, lowered_qubit), + ) => { + source_qubit == lowered_qubit + && Self::same_float(*source_theta, *lowered_theta) + && Self::same_float(*source_phi, *lowered_phi) + } + ( + QuantumOp::CX(source_control, source_target), + QuantumOp::CX(lowered_control, lowered_target), + ) + | ( + QuantumOp::CY(source_control, source_target), + QuantumOp::CY(lowered_control, lowered_target), + ) + | ( + QuantumOp::CZ(source_control, source_target), + QuantumOp::CZ(lowered_control, lowered_target), + ) + | ( + QuantumOp::CH(source_control, source_target), + QuantumOp::CH(lowered_control, lowered_target), + ) => Self::same_pair( + *source_control, + *source_target, + *lowered_control, + *lowered_target, + ), + ( + QuantumOp::CRZ(source_theta, source_control, source_target), + QuantumOp::CRZ(lowered_theta, lowered_control, lowered_target), + ) => { + Self::same_float(*source_theta, *lowered_theta) + && Self::same_pair( + *source_control, + *source_target, + *lowered_control, + *lowered_target, ) + } + ( + QuantumOp::CCX(source_control_1, source_control_2, source_target), + QuantumOp::CCX(lowered_control_1, lowered_control_2, lowered_target), + ) => { + (source_control_1, source_control_2, source_target) + == (lowered_control_1, lowered_control_2, lowered_target) + } + ( + QuantumOp::ZZ(source_qubit_1, source_qubit_2), + QuantumOp::ZZ(lowered_qubit_1, lowered_qubit_2) + | QuantumOp::RZZ(_, lowered_qubit_1, lowered_qubit_2), + ) => Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ), + ( + QuantumOp::RZZ(source_theta, source_qubit_1, source_qubit_2), + QuantumOp::RZZ(lowered_theta, lowered_qubit_1, lowered_qubit_2), + ) => { + Self::same_float(*source_theta, *lowered_theta) + && Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ) + } + ( + QuantumOp::Measure(source_qubit, source_result), + QuantumOp::Measure(lowered_qubit, lowered_result), + ) => source_qubit == lowered_qubit && source_result == lowered_result, + _ => false, + } + } + + fn same_float(left: f64, right: f64) -> bool { + (left - right).abs() <= 1e-12 + } + + fn same_pair(left_a: usize, left_b: usize, right_a: usize, right_b: usize) -> bool { + (left_a, left_b) == (right_a, right_b) + } + + fn same_unordered_pair(left_a: usize, left_b: usize, right_a: usize, right_b: usize) -> bool { + Self::same_pair(left_a, left_b, right_a, right_b) + || Self::same_pair(left_a, left_b, right_b, right_a) + } + + fn quantum_op_qubits(qop: &QuantumOp) -> BTreeSet { + let mut qubits = BTreeSet::new(); + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) + | QuantumOp::Idle(_, qubit) + | QuantumOp::Measure(qubit, _) + | QuantumOp::Reset(qubit) => { + qubits.insert(*qubit); + } + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => { + qubits.insert(*qubit_1); + qubits.insert(*qubit_2); + } + QuantumOp::CCX(qubit_1, qubit_2, qubit_3) => { + qubits.insert(*qubit_1); + qubits.insert(*qubit_2); + qubits.insert(*qubit_3); + } + } + qubits + } + + fn single_qubit_gate_qubit(qop: &QuantumOp) -> Option { + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) => Some(*qubit), + _ => None, + } + } + + fn two_qubit_gate_qubits(qop: &QuantumOp) -> Option<(usize, usize)> { + match qop { + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => Some((*qubit_1, *qubit_2)), + _ => None, + } + } + + fn fail_if_metadata_was_not_lowered( + source_metadata: &VecDeque, + ) -> Result<()> { + let leftover_metadata = source_metadata + .iter() + .filter(|source| Self::trace_metadata_requires_lowering(&source.metadata)) + .collect::>(); + if leftover_metadata.is_empty() { + return Ok(()); + } + let examples = leftover_metadata + .iter() + .take(3) + .map(|source| format!("{:?} for {:?}", source.metadata, source.op)) + .collect::>() + .join(", "); + Err(RuntimeError::ExecutionError(format!( + "runtime lowering did not emit non-idle operations for {} metadata-bearing source operation(s); examples: {examples}", + leftover_metadata.len() + ))) + } + + fn trace_metadata_requires_lowering(metadata: &TraceMetadata) -> bool { + metadata + .get("source_lowering_required") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + } + + fn fail_if_qubit_metadata_was_not_consumed( + pending_qubit_metadata: &BTreeMap, + ) -> Result<()> { + if pending_qubit_metadata.is_empty() { + return Ok(()); + } + let examples = pending_qubit_metadata + .iter() + .take(3) + .map(|(qubit, metadata)| format!("qubit {qubit}: {metadata:?}")) + .collect::>() + .join(", "); + Err(RuntimeError::ExecutionError(format!( + "qubit-scoped trace metadata was not followed by a compatible quantum operation for {} qubit(s); examples: {examples}", + pending_qubit_metadata.len() + ))) + } + + fn convert_runtime_batch(&mut self, batch: RuntimeOperationBatch) -> Result> { + let mut lowered_ops = Vec::new(); + let start_time = batch.start_time_nanos; + let end_time = batch.end_time_nanos(); + + for op in batch.operations { + match op { + RuntimeScheduledOp::Rxy { + qubit_id, + theta, + phi, + } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::RXY(theta, phi, qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Rz { qubit_id, theta } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::RZ(theta, qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Rzz { + qubit_id_1, + qubit_id_2, + theta, + } => { + let qubit_1 = self.runtime_qubit_to_usize(qubit_id_1)?; + let qubit_2 = self.runtime_qubit_to_usize(qubit_id_2)?; + self.push_idle_before(&mut lowered_ops, qubit_1, start_time)?; + self.push_idle_before(&mut lowered_ops, qubit_2, start_time)?; + lowered_ops.push(QuantumOp::RZZ(theta, qubit_1, qubit_2)); + self.mark_gate_end(qubit_1, end_time); + self.mark_gate_end(qubit_2, end_time); + } + RuntimeScheduledOp::Measure { + qubit_id, + result_id, + } + | RuntimeScheduledOp::MeasureLeaked { + qubit_id, + result_id, + } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + let program_result = self.runtime_result_to_program_result(result_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::Measure(qubit, program_result)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Reset { qubit_id } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + lowered_ops.push(QuantumOp::Reset(qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Custom => {} + } + } + + Ok(lowered_ops) + } + + fn runtime_qubit_to_usize(&mut self, runtime_qubit: u64) -> Result { + let qubit = usize::try_from(runtime_qubit).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime qubit id {runtime_qubit} does not fit in usize" + )) + })?; + self.ensure_timing_slot(qubit); + Ok(qubit) + } + + fn runtime_result_to_program_result(&self, runtime_result: u64) -> Result { + if let Some(&program_result) = self.runtime_to_program_results.get(&runtime_result) { + return Ok(program_result); + } + + usize::try_from(runtime_result).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime result id {runtime_result} does not fit in usize" + )) + }) + } + + fn ensure_timing_slot(&mut self, qubit: usize) { + if self.last_gate_time_end_nanos.len() <= qubit { + self.last_gate_time_end_nanos.resize(qubit + 1, 0); + } + } + + fn push_idle_before( + &mut self, + lowered_ops: &mut Vec, + qubit: usize, + start_time_nanos: u64, + ) -> Result<()> { + self.ensure_timing_slot(qubit); + let last_gate_end = self.last_gate_time_end_nanos[qubit]; + if last_gate_end > start_time_nanos { + return Err(RuntimeError::ExecutionError(format!( + "Runtime operation on qubit {qubit} starts before its previous operation ended: {start_time_nanos} < {last_gate_end}" + ))); + } + + let idle_time = start_time_nanos - last_gate_end; + if idle_time > 0 { + lowered_ops.push(QuantumOp::Idle(nanoseconds_to_seconds(idle_time), qubit)); + } + + Ok(()) + } + + fn mark_gate_end(&mut self, qubit: usize, end_time_nanos: u64) { + self.ensure_timing_slot(qubit); + self.last_gate_time_end_nanos[qubit] = end_time_nanos; + } +} + +fn nanoseconds_to_seconds(nanoseconds: u64) -> f64 { + std::time::Duration::from_nanos(nanoseconds).as_secs_f64() +} + +impl Clone for SeleneRuntime { + fn clone(&self) -> Self { + // For now, create a new instance with the same plugin path + // The library itself can't be cloned, so we'll reload if needed + Self { + plugin_path: self.plugin_path.clone(), + init_args: self.init_args.clone(), + library_search_dirs: self.library_search_dirs.clone(), + library: None, // Will be reloaded on demand + instance: None, // Will be recreated on demand + initialized_num_qubits: None, + state: self.state.clone(), + operations_buffer: self.operations_buffer.clone(), + batch_size: self.batch_size, + num_qubits: self.num_qubits, + num_qubits_hint: self.num_qubits_hint, + uses_explicit_qubit_allocation: self.uses_explicit_qubit_allocation, + num_results: self.num_results, + interface: self.interface.clone(), + current_op_index: self.current_op_index, + needs_reexecution: self.needs_reexecution, + pending_measurements: self.pending_measurements.clone(), + program_to_runtime_qubits: self.program_to_runtime_qubits.clone(), + program_to_runtime_results: self.program_to_runtime_results.clone(), + runtime_to_program_results: self.runtime_to_program_results.clone(), + last_gate_time_end_nanos: self.last_gate_time_end_nanos.clone(), + pending_shot_start: self.pending_shot_start, + } + } +} + +fn collector_capacity(interface: &OperationCollector) -> (usize, usize) { + let uses_explicit_allocations = has_explicit_qubit_allocations(&interface.operations); + let (mut num_qubits, mut num_results) = + operation_capacity_with_mode(&interface.operations, uses_explicit_allocations); + + if !uses_explicit_allocations { + for &qubit in &interface.allocated_qubits { + include_qubit(&mut num_qubits, qubit); + } + } + for &result in &interface.allocated_results { + include_result(&mut num_results, result); + } + + (num_qubits, num_results) +} + +fn has_explicit_qubit_allocations(operations: &[Operation]) -> bool { + operations.iter().any(|op| { + matches!( + op, + Operation::AllocateQubit { .. } | Operation::ReleaseQubit { .. } + ) + }) +} + +fn operation_capacity_with_mode( + operations: &[Operation], + uses_explicit_allocations: bool, +) -> (usize, usize) { + let mut num_qubits = 0; + let mut num_results = 0; + let mut live_qubits = BTreeSet::new(); + let mut max_live_qubits = 0; + + for op in operations { + match op { + Operation::Quantum(qop) if !uses_explicit_allocations => { + include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results); + } + Operation::Quantum(qop) => { + include_quantum_result_capacity(qop, &mut num_results); + } + Operation::AllocateQubit { id } => { + live_qubits.insert(*id); + max_live_qubits = max_live_qubits.max(live_qubits.len()); + } + Operation::ReleaseQubit { id } => { + live_qubits.remove(id); + } + Operation::AllocateResult { id } => include_result(&mut num_results, *id), + Operation::RecordOutput { result_id, .. } => { + include_result(&mut num_results, *result_id); + } + Operation::TraceMetadata { .. } | Operation::Barrier => {} + } + } + if uses_explicit_allocations { + num_qubits = max_live_qubits; + } + + (num_qubits, num_results) +} + +fn include_quantum_result_capacity(qop: &QuantumOp, num_results: &mut usize) { + if let QuantumOp::Measure(_, result) = qop { + include_result(num_results, *result); + } +} + +fn include_quantum_op_capacity(qop: &QuantumOp, num_qubits: &mut usize, num_results: &mut usize) { + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) + | QuantumOp::Idle(_, qubit) + | QuantumOp::Reset(qubit) => include_qubit(num_qubits, *qubit), + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => { + include_qubit(num_qubits, *qubit_1); + include_qubit(num_qubits, *qubit_2); + } + QuantumOp::CCX(qubit_1, qubit_2, qubit_3) => { + include_qubit(num_qubits, *qubit_1); + include_qubit(num_qubits, *qubit_2); + include_qubit(num_qubits, *qubit_3); + } + QuantumOp::Measure(qubit, result) => { + include_qubit(num_qubits, *qubit); + include_result(num_results, *result); + } + } +} + +fn include_qubit(num_qubits: &mut usize, qubit: usize) { + *num_qubits = (*num_qubits).max(qubit + 1); +} + +fn include_result(num_results: &mut usize, result: usize) { + *num_results = (*num_results).max(result + 1); +} + +impl QisRuntime for SeleneRuntime { + fn load_interface(&mut self, interface: OperationCollector) -> Result<()> { + debug!( + "Loading QIS interface with {} operations", + interface.operations.len() + ); + + // Count qubits from explicit allocation records when present, + // otherwise from direct program handles. Some legacy LLVM/QIR inputs + // use qubit handles like 0 and 1 without emitting allocation calls. + let (num_qubits, num_results) = collector_capacity(&interface); + self.num_qubits = num_qubits; + self.num_results = num_results; + self.uses_explicit_qubit_allocation = has_explicit_qubit_allocations(&interface.operations); + + debug!( + "Interface has {} qubits and {} result slots", + self.num_qubits, self.num_results + ); + + self.interface = Some(interface); + self.current_op_index = 0; + self.needs_reexecution = false; + self.pending_measurements.clear(); + + // Don't load the plugin yet - defer until actually needed + // This allows creating and testing the runtime without a real .so file + + Ok(()) + } + + fn execute_until_quantum(&mut self) -> Result>> { + // For now, we'll use the simple approach of processing from the interface + // In a full implementation, we'd call into the Selene runtime's + // get_next_operations function + self.process_interface_ops() + } + + fn supports_operation_lowering(&self) -> bool { + true + } + + fn lower_operations(&mut self, operations: &[Operation]) -> Result> { + if has_explicit_qubit_allocations(operations) { + self.uses_explicit_qubit_allocation = true; + } + let (num_qubits, num_results) = + operation_capacity_with_mode(operations, self.uses_explicit_qubit_allocation); + self.num_qubits = self.num_qubits.max(num_qubits); + self.num_results = self.num_results.max(num_results); + self.ensure_plugin_capacity()?; + self.load_plugin()?; + let mut lowered_ops = Vec::new(); + + for op in operations { + if matches!(op, Operation::Barrier) { + lowered_ops.extend(self.lower_runtime_barrier()?); + } + self.submit_operation_to_runtime(op, &mut lowered_ops)?; + } + + lowered_ops.extend(self.drain_runtime_operations()?); + Ok(lowered_ops) + } + + fn lower_operations_with_metadata( + &mut self, + operations: &[Operation], + ) -> Result> { + if has_explicit_qubit_allocations(operations) { + self.uses_explicit_qubit_allocation = true; + } + let (num_qubits, num_results) = + operation_capacity_with_mode(operations, self.uses_explicit_qubit_allocation); + self.num_qubits = self.num_qubits.max(num_qubits); + self.num_results = self.num_results.max(num_results); + self.ensure_plugin_capacity()?; + self.load_plugin()?; + + let mut lowered_ops = Vec::new(); + let mut source_metadata = VecDeque::new(); + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata: BTreeMap = BTreeMap::new(); + + for op in operations { + match op { + Operation::TraceMetadata { metadata, qubit } => { + if let Some(qubit) = qubit { + let pending = pending_qubit_metadata.entry(*qubit).or_default(); + Self::merge_trace_metadata(pending, metadata.clone())?; + } else { + Self::merge_trace_metadata(&mut pending_global_metadata, metadata.clone())?; + } + } + Operation::Quantum(qop) => { + let metadata = Self::take_pending_trace_metadata_for_source_op( + qop, + &mut pending_global_metadata, + &mut pending_qubit_metadata, + )?; + if !metadata.is_empty() { + let source_op = self.map_quantum_op_to_runtime_qubits(qop)?; + source_metadata.push_back(SourceTraceMetadata { + op: source_op, + metadata, + }); + } + let mut emitted_ops = Vec::new(); + self.submit_operation_to_runtime(op, &mut emitted_ops)?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } + Operation::Barrier => { + let emitted_ops = self.lower_runtime_barrier()?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } + _ => { + let mut emitted_ops = Vec::new(); + self.submit_operation_to_runtime(op, &mut emitted_ops)?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } + } + } + + let emitted_ops = self.drain_runtime_operations()?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + + if !pending_global_metadata.is_empty() { + return Err(RuntimeError::ExecutionError(format!( + "trace metadata was not followed by a quantum operation: {pending_global_metadata:?}" + ))); + } + Self::fail_if_qubit_metadata_was_not_consumed(&pending_qubit_metadata)?; + Self::fail_if_metadata_was_not_lowered(&source_metadata)?; + + Ok(lowered_ops) + } + + fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()> { + debug!( + "Received {} measurement results, num_results={}, allocated_results={:?}", + measurements.len(), + self.num_results, + self.interface.as_ref().map(|i| &i.allocated_results) + ); + + // Store measurements in classical state + for (result_id, value) in &measurements { + trace!( + "Measurement result {} = {} (num_results={})", + result_id, value, self.num_results + ); + self.state.measurements.insert(*result_id, *value); + + if let Some(runtime_result_id) = self.program_to_runtime_results.get(result_id) { + if let Some(lib) = &self.library + && let Some(instance) = self.instance { - let errno = shot_start_fn(instance, shot_id, seed.unwrap_or(0)); - if errno != 0 { - return Err(RuntimeError::ExecutionError(format!( - "Shot start failed with errno {errno}" - ))); + unsafe { + if let Ok(set_result_fn) = + lib.get:: i32>( + b"selene_runtime_set_bool_result", + ) + { + let errno = set_result_fn(instance, *runtime_result_id, *value); + if errno != 0 { + log::trace!( + "Selene runtime returned error {errno} for result {result_id}" + ); + } + } } } + } else { + log::trace!( + "Measurement result {result_id} was not allocated by the Selene runtime, storing locally only" + ); + } + + if let Some(interface) = &mut self.interface { + interface.store_result(*result_id, *value); } } + // Check if there are remaining operations that might depend on these measurements + // If so, we need to re-execute the program with the known measurement values + // so that conditionals can evaluate correctly + if let Some(interface) = &self.interface { + let remaining_ops = interface + .operations + .len() + .saturating_sub(self.current_op_index); + if remaining_ops > 0 && !measurements.is_empty() { + debug!( + "Setting needs_reexecution=true: {} ops remaining after {} measurements", + remaining_ops, + measurements.len() + ); + self.needs_reexecution = true; + } + } + + Ok(()) + } + + fn get_classical_state(&self) -> &ClassicalState { + &self.state + } + + fn get_classical_state_mut(&mut self) -> &mut ClassicalState { + &mut self.state + } + + fn is_complete(&self) -> bool { + self.interface + .as_ref() + .is_none_or(|i| self.current_op_index >= i.operations.len()) + } + + fn num_qubits(&self) -> usize { + self.plugin_num_qubits() + } + + fn set_num_qubits(&mut self, num_qubits: usize) { + self.num_qubits_hint = Some(num_qubits); + self.num_qubits = self.num_qubits.max(num_qubits); + } + + fn set_batch_size(&mut self, size: usize) { + self.batch_size = size; + } + + fn needs_reexecution(&self) -> bool { + self.needs_reexecution + } + + fn clear_reexecution_flag(&mut self) { + self.needs_reexecution = false; + } + + fn reload_operations(&mut self, operations: OperationCollector) { + SeleneRuntime::reload_operations(self, operations); + } + + fn shot_start(&mut self, shot_id: u64, seed: Option) -> Result<()> { // Reset state for new shot self.state = ClassicalState::default(); self.current_op_index = 0; self.needs_reexecution = false; self.pending_measurements.clear(); + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); + self.pending_shot_start = Some((shot_id, seed)); + self.apply_pending_shot_start()?; Ok(()) } @@ -477,15 +1973,14 @@ impl QisRuntime for SeleneRuntime { && let Some(instance) = self.instance { unsafe { - if let Ok(shot_end_fn) = lib - .get:: i32>( - b"selene_runtime_shot_end", - ) + if let Ok(shot_end_fn) = + lib.get:: i32>(b"selene_runtime_shot_end") { - let _ = shot_end_fn(instance, 0, 0); + let _ = shot_end_fn(instance); } } } + self.pending_shot_start = None; // Return the shot with measurements and registers let shot = Shot { @@ -498,23 +1993,14 @@ impl QisRuntime for SeleneRuntime { } fn reset(&mut self) -> Result<()> { - // Clean up the runtime instance - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(exit_fn) = - lib.get:: i32>(b"selene_runtime_exit") - { - let _ = exit_fn(instance); - } - } - } - - self.instance = None; - self.library = None; + self.reset_plugin_instance()?; self.state = ClassicalState::default(); self.current_op_index = 0; + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); + self.pending_shot_start = None; Ok(()) } @@ -556,4 +2042,376 @@ mod tests { assert_eq!(runtime.num_qubits(), 0); assert!(runtime.is_complete()); } + + #[test] + fn test_selene_runtime_plugin_config_clones() { + let runtime = SeleneRuntime::with_plugin_config( + "/path/to/selene.so", + vec!["--duration-ns-rxy=10".to_string()], + vec![PathBuf::from("/path/to/lib")], + ); + let cloned = runtime.clone(); + assert_eq!(cloned.init_args, ["--duration-ns-rxy=10"]); + assert_eq!(cloned.library_search_dirs, [PathBuf::from("/path/to/lib")]); + } + + #[test] + fn test_runtime_batch_timing_inserts_idle() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + let batch = RuntimeOperationBatch { + start_time_nanos: 20, + duration_nanos: 5, + invoked: true, + operations: vec![RuntimeScheduledOp::Rxy { + qubit_id: 0, + theta: 1.0, + phi: 0.5, + }], + }; + + let ops = runtime.convert_runtime_batch(batch).unwrap(); + assert_eq!( + ops, + vec![QuantumOp::Idle(20e-9, 0), QuantumOp::RXY(1.0, 0.5, 0)] + ); + } + + #[test] + fn test_source_metadata_attaches_to_first_non_idle_lowered_op() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::Idle(20e-9, 0), QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert!(lowered_ops[0].metadata.is_empty()); + assert_eq!( + lowered_ops[1] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:szz-host") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_source_idle_metadata_can_attach_to_idle_op() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_label".to_string(), "probe:idle".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::Idle(20e-9, 0), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::Idle(20e-9, 0)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:idle") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_unmatched_metadata_does_not_block_later_compatible_metadata() { + let mut rz_metadata = TraceMetadata::new(); + rz_metadata.insert("source_label".to_string(), "probe:virtual-rz".to_string()); + let mut rzz_metadata = TraceMetadata::new(); + rzz_metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + let mut source_metadata = VecDeque::from([ + SourceTraceMetadata { + op: QuantumOp::RZ(0.25, 0), + metadata: rz_metadata, + }, + SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata: rzz_metadata, + }, + ]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:szz-host") + ); + assert_eq!(source_metadata.len(), 1); + assert_eq!( + source_metadata[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:virtual-rz") + ); + } + + #[test] + fn test_source_metadata_can_attach_after_runtime_reordering() { + let mut first_metadata = TraceMetadata::new(); + first_metadata.insert("source_label".to_string(), "probe:first".to_string()); + let mut second_metadata = TraceMetadata::new(); + second_metadata.insert("source_label".to_string(), "probe:second".to_string()); + let mut source_metadata = VecDeque::from([ + SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata: first_metadata, + }, + SourceTraceMetadata { + op: QuantumOp::RZZ(-0.5, 2, 3), + metadata: second_metadata, + }, + ]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RZZ(-0.5, 2, 3), QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:second") + ); + assert_eq!( + lowered_ops[1] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:first") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_source_gate_metadata_matches_runtime_normalized_single_qubit_pulse() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_gate".to_string(), "H".to_string()); + metadata.insert("source_label".to_string(), "probe:h-prefix".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RXY(std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2, 2), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 2)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:h-prefix") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_qubit_scoped_metadata_waits_for_compatible_source_op() { + let mut h_metadata = TraceMetadata::new(); + h_metadata.insert("source_gate".to_string(), "H".to_string()); + h_metadata.insert("source_label".to_string(), "probe:h-prefix".to_string()); + let mut szz_metadata = TraceMetadata::new(); + szz_metadata.insert("source_gate".to_string(), "SZZ".to_string()); + szz_metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata = BTreeMap::from([(1, szz_metadata), (8, h_metadata)]); + + let rxy_metadata = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 8), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect("take metadata for RXY"); + assert_eq!( + rxy_metadata.get("source_label").map(String::as_str), + Some("probe:h-prefix") + ); + assert!(pending_qubit_metadata.contains_key(&1)); + assert!(!pending_qubit_metadata.contains_key(&8)); + + let rzz_metadata = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RZZ(-std::f64::consts::FRAC_PI_2, 9, 1), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect("take metadata for RZZ"); + assert_eq!( + rzz_metadata.get("source_label").map(String::as_str), + Some("probe:szz-host") + ); + assert!(pending_qubit_metadata.is_empty()); + } + + #[test] + fn test_conflicting_trace_metadata_fails_loudly() { + let mut left_metadata = TraceMetadata::new(); + left_metadata.insert("source_label".to_string(), "probe:left".to_string()); + let mut right_metadata = TraceMetadata::new(); + right_metadata.insert("source_label".to_string(), "probe:right".to_string()); + + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata = BTreeMap::from([(0, left_metadata), (1, right_metadata)]); + + let error = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RZZ(std::f64::consts::FRAC_PI_2, 0, 1), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect_err("conflicting source labels should fail"); + assert!(error.to_string().contains("conflicting trace metadata")); + } + + #[test] + fn test_optional_unlowered_trace_metadata_is_allowed() { + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "probe:optimized-away-prefix".to_string(), + ); + let source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 4), + metadata, + }]); + + SeleneRuntime::fail_if_metadata_was_not_lowered(&source_metadata) + .expect("optional metadata may be optimized away by the runtime"); + } + + #[test] + fn test_required_unlowered_trace_metadata_fails_loudly() { + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "probe:required-host".to_string(), + ); + metadata.insert("source_lowering_required".to_string(), "true".to_string()); + let source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RZZ(std::f64::consts::FRAC_PI_2, 0, 1), + metadata, + }]); + + let error = SeleneRuntime::fail_if_metadata_was_not_lowered(&source_metadata) + .expect_err("required metadata should fail when it is not lowered"); + assert!(error.to_string().contains("required-host")); + } + + #[test] + fn test_collector_capacity_includes_direct_program_handles() { + let mut collector = OperationCollector::new(); + collector.queue_operation(QuantumOp::H(0).into()); + collector.queue_operation(QuantumOp::CX(0, 3).into()); + collector.queue_operation(QuantumOp::Measure(3, 7).into()); + collector.queue_operation(Operation::RecordOutput { + result_id: 7, + register_name: "c".to_string(), + }); + + assert_eq!(collector_capacity(&collector), (4, 8)); + } + + #[test] + fn test_collector_capacity_includes_explicit_allocations() { + let mut collector = OperationCollector::new(); + collector.queue_operation(Operation::AllocateQubit { id: 5 }); + collector.queue_operation(Operation::AllocateResult { id: 2 }); + collector.queue_operation(QuantumOp::H(5).into()); + + assert_eq!(collector_capacity(&collector), (1, 3)); + } + + #[test] + fn test_collector_capacity_uses_max_live_explicit_allocations() { + let mut collector = OperationCollector::new(); + collector.queue_operation(Operation::AllocateQubit { id: 81 }); + collector.queue_operation(Operation::AllocateQubit { id: 97 }); + collector.queue_operation(QuantumOp::CX(81, 97).into()); + collector.queue_operation(Operation::ReleaseQubit { id: 97 }); + collector.queue_operation(Operation::AllocateQubit { id: 105 }); + collector.queue_operation(QuantumOp::Measure(105, 9).into()); + + assert_eq!(collector_capacity(&collector), (2, 10)); + } + + #[test] + fn test_explicit_qubit_hint_caps_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.set_num_qubits(98); + + let (num_qubits, _) = operation_capacity_with_mode( + &[QuantumOp::CX(81, 105).into()], + runtime.uses_explicit_qubit_allocation, + ); + runtime.num_qubits = runtime.num_qubits.max(num_qubits); + + assert_eq!(runtime.num_qubits, 106); + assert_eq!(runtime.plugin_num_qubits(), 98); + assert_eq!(runtime.num_qubits(), 98); + } + + #[test] + fn test_shot_start_defers_until_plugin_load() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.shot_start(42, Some(1234)).unwrap(); + + assert_eq!(runtime.pending_shot_start, Some((42, Some(1234)))); + + runtime.shot_end().unwrap(); + assert_eq!(runtime.pending_shot_start, None); + } + + #[test] + fn test_clone_does_not_reuse_initialized_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.num_qubits = 3; + runtime.initialized_num_qubits = Some(3); + + let cloned = runtime.clone(); + + assert_eq!(cloned.num_qubits, 3); + assert_eq!(cloned.initialized_num_qubits, None); + } + + #[test] + fn test_reset_clears_initialized_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.initialized_num_qubits = Some(3); + + runtime.reset().unwrap(); + + assert_eq!(runtime.initialized_num_qubits, None); + } } diff --git a/crates/pecos-quantum/src/dag_circuit.rs b/crates/pecos-quantum/src/dag_circuit.rs index 7086a6dcf..77de172ff 100644 --- a/crates/pecos-quantum/src/dag_circuit.rs +++ b/crates/pecos-quantum/src/dag_circuit.rs @@ -1832,6 +1832,12 @@ impl DagCircuit { self.annotations.push(ann); } + /// Add a pre-built annotation when the corresponding tracked-Pauli meta + /// gate has already been inserted into the DAG. + pub(crate) fn add_annotation_without_meta_gate(&mut self, ann: PauliAnnotation) { + self.annotations.push(ann); + } + /// Get detector annotations. pub fn detectors(&self) -> impl Iterator { self.annotations diff --git a/crates/pecos-quantum/src/hugr_convert.rs b/crates/pecos-quantum/src/hugr_convert.rs index 1d3404c3f..395549fb7 100644 --- a/crates/pecos-quantum/src/hugr_convert.rs +++ b/crates/pecos-quantum/src/hugr_convert.rs @@ -28,7 +28,7 @@ use tket::TketOp; use tket::extension::rotation::ConstRotation; use tket::hugr::builder::{DFGBuilder, Dataflow, DataflowHugr}; use tket::hugr::extension::prelude::qb_t; -use tket::hugr::ops::OpType; +use tket::hugr::ops::{OpTrait, OpType}; use tket::hugr::types::Signature; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, NodeIndex, PortIndex, Wire}; @@ -462,6 +462,21 @@ fn trace_back_for_const(hugr: &Hugr, node: Node, depth: usize) -> Option<(f64, b if let Some((src_node, _)) = hugr.single_linked_output(node, input_port) { return trace_back_for_const(hugr, src_node, depth + 1); } + } else if format!("{op:?}").contains("MakeTuple") + && op + .dataflow_signature() + .is_some_and(|sig| sig.input_count() == 1) + { + // Guppy wraps a rotation angle in a 1-tuple between the constant and + // `from_halfturns_unchecked` (Const -> LoadConstant -> MakeTuple -> + // UnpackTuple -> from_halfturns_unchecked), so trace through the + // single element. Multi-element tuples are ambiguous here (the + // element index is not tracked), so those fall through to the + // runtime value path. + let input_port = IncomingPort::from(0); + if let Some((src_node, _)) = hugr.single_linked_output(node, input_port) { + return trace_back_for_const(hugr, src_node, depth + 1); + } } // For Call nodes, try to evaluate if it's an arithmetic operation diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index 44bf47a1b..4c4db3667 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -86,8 +86,8 @@ pub use dag_circuit::{ TraversalWorkBuffers, }; pub use tick_circuit::{ - CustomGateError, GateSignatureMismatchError, QubitConflictError, Tick, TickCircuit, - TickGateError, TickHandle, TickMeasRef, TickMeasureHandle, TickPrepHandle, + CustomGateError, GateSignatureMismatchError, PHYSICAL_DURATION_META_KEY, QubitConflictError, + Tick, TickCircuit, TickGateError, TickHandle, TickMeasRef, TickMeasureHandle, TickPrepHandle, }; // Re-export commonly used types from dependencies diff --git a/crates/pecos-quantum/src/pass.rs b/crates/pecos-quantum/src/pass.rs index 98160d49b..6b74064da 100644 --- a/crates/pecos-quantum/src/pass.rs +++ b/crates/pecos-quantum/src/pass.rs @@ -21,7 +21,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use pecos_core::gate_type::GateType; -use pecos_core::{Angle64, Gate, GateQubits, QubitId}; +use pecos_core::{Angle64, Clifford, Gate, GateQubits, QubitId}; use crate::{Attribute, DagCircuit, Tick, TickCircuit}; @@ -106,6 +106,11 @@ pub fn absorb_basis_gates(circuit: &mut TickCircuit) { AbsorbBasisGates.apply_tick(circuit); } +/// Simplify adjacent single-qubit Clifford chains on each qubit. +pub fn simplify_single_qubit_clifford_chains(circuit: &mut TickCircuit) { + SimplifySingleQubitCliffordChains.apply_tick(circuit); +} + /// Compact ticks by ASAP scheduling (merge gates into earlier ticks). pub fn compact_ticks(circuit: &mut TickCircuit) { CompactTicks.apply_tick(circuit); @@ -1169,6 +1174,241 @@ impl CircuitPass for AbsorbBasisGates { } } +const SINGLE_QUBIT_CLIFFORD_CANDIDATES: [GateType; 12] = [ + GateType::Z, + GateType::SZ, + GateType::SZdg, + GateType::X, + GateType::Y, + GateType::H, + GateType::SX, + GateType::SXdg, + GateType::SY, + GateType::SYdg, + GateType::F, + GateType::Fdg, +]; + +#[derive(Clone, Debug)] +struct SingleQubitCliffordChain { + positions: Vec<(usize, usize)>, + gates: Vec, + product: Clifford, +} + +impl SingleQubitCliffordChain { + fn new(position: (usize, usize), gate_type: GateType, clifford: Clifford) -> Self { + Self { + positions: vec![position], + gates: vec![gate_type], + product: clifford, + } + } + + fn push(&mut self, position: (usize, usize), gate_type: GateType, clifford: Clifford) { + self.positions.push(position); + self.gates.push(gate_type); + self.product = clifford.compose(self.product); + } +} + +fn gate_type_to_single_qubit_clifford(gate_type: GateType) -> Option { + match gate_type { + GateType::I => Some(Clifford::I), + GateType::X => Some(Clifford::X), + GateType::Y => Some(Clifford::Y), + GateType::Z => Some(Clifford::Z), + GateType::H => Some(Clifford::H), + GateType::SX => Some(Clifford::SX), + GateType::SXdg => Some(Clifford::SXdg), + GateType::SY => Some(Clifford::SY), + GateType::SYdg => Some(Clifford::SYdg), + GateType::SZ => Some(Clifford::SZ), + GateType::SZdg => Some(Clifford::SZdg), + GateType::F => Some(Clifford::F), + GateType::Fdg => Some(Clifford::Fdg), + _ => None, + } +} + +fn plain_single_qubit_clifford_gate(gate: &Gate) -> Option { + if gate.qubits.len() != 1 + || !gate.angles.is_empty() + || !gate.params.is_empty() + || !gate.meas_ids.is_empty() + || gate.channel.is_some() + { + return None; + } + gate_type_to_single_qubit_clifford(gate.gate_type) +} + +fn single_qubit_clifford_sequence_product(sequence: &[GateType]) -> Clifford { + let mut product = Clifford::I; + for &gate_type in sequence { + let clifford = gate_type_to_single_qubit_clifford(gate_type) + .expect("candidate gate types must be one-qubit Cliffords"); + product = clifford.compose(product); + } + product +} + +fn is_z_axis_frame_candidate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::I | GateType::Z | GateType::SZ | GateType::SZdg + ) +} + +fn single_qubit_clifford_sequence_score(sequence: &[GateType]) -> (usize, usize) { + let non_frame_count = sequence + .iter() + .filter(|&&gate_type| !is_z_axis_frame_candidate(gate_type)) + .count(); + (non_frame_count, sequence.len()) +} + +fn canonical_single_qubit_clifford_sequence(clifford: Clifford) -> Vec { + if clifford == Clifford::I { + return Vec::new(); + } + + let mut best_sequence: Option> = None; + let mut best_score: Option<(usize, usize)> = None; + + for &candidate in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + let sequence = vec![candidate]; + if single_qubit_clifford_sequence_product(&sequence) == clifford { + let score = single_qubit_clifford_sequence_score(&sequence); + if best_score.is_none_or(|best| score < best) { + best_score = Some(score); + best_sequence = Some(sequence); + } + } + } + + for &first in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + for &second in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + let sequence = vec![first, second]; + if single_qubit_clifford_sequence_product(&sequence) == clifford { + let score = single_qubit_clifford_sequence_score(&sequence); + if best_score.is_none_or(|best| score < best) { + best_score = Some(score); + best_sequence = Some(sequence); + } + } + } + } + + best_sequence.unwrap_or_else(|| { + panic!("no existing-gate decomposition found for one-qubit Clifford {clifford}") + }) +} + +fn flush_single_qubit_clifford_chain( + chain: &SingleQubitCliffordChain, + replacements: &mut BTreeMap<(usize, usize), GateType>, + to_remove: &mut HashSet<(usize, usize)>, +) { + if chain.positions.len() < 2 { + return; + } + + let canonical = canonical_single_qubit_clifford_sequence(chain.product); + let original_score = single_qubit_clifford_sequence_score(&chain.gates); + let canonical_score = single_qubit_clifford_sequence_score(&canonical); + if canonical_score >= original_score || canonical.len() > chain.positions.len() { + return; + } + + for (position, gate_type) in chain.positions.iter().zip(canonical.iter()) { + replacements.insert(*position, *gate_type); + } + for position in chain.positions.iter().skip(canonical.len()) { + to_remove.insert(*position); + } +} + +/// Simplify adjacent single-qubit Clifford chains on each qubit. +/// +/// The pass follows each qubit's operation timeline, composes adjacent plain +/// one-qubit Clifford gates exactly, and replaces the chain with a deterministic +/// sequence over existing PECOS gate names. Gates carrying parameters, +/// measurement IDs, channel payloads, or batch metadata are treated as barriers. +pub struct SimplifySingleQubitCliffordChains; + +impl CircuitPass for SimplifySingleQubitCliffordChains { + fn apply_tick(&self, circuit: &mut TickCircuit) { + split_batched_tick_commands(circuit); + + let mut pending: BTreeMap = BTreeMap::new(); + let mut replacements: BTreeMap<(usize, usize), GateType> = BTreeMap::new(); + let mut to_remove: HashSet<(usize, usize)> = HashSet::new(); + + for (ti, tick) in circuit.iter_ticks() { + for gate_ref in tick.iter_gate_batches() { + let position = (ti, gate_ref.batch_index()); + let gate = gate_ref.as_gate(); + + if gate_ref.attrs().next().is_none() + && let Some(clifford) = plain_single_qubit_clifford_gate(gate) + { + let qubit = gate.qubits[0]; + if let Some(chain) = pending.get_mut(&qubit) { + chain.push(position, gate.gate_type, clifford); + } else { + pending.insert( + qubit, + SingleQubitCliffordChain::new(position, gate.gate_type, clifford), + ); + } + continue; + } + + for &qubit in &gate.qubits { + if let Some(chain) = pending.remove(&qubit) { + flush_single_qubit_clifford_chain( + &chain, + &mut replacements, + &mut to_remove, + ); + } + } + } + } + + for (_, chain) in pending { + flush_single_qubit_clifford_chain(&chain, &mut replacements, &mut to_remove); + } + + for (&(ti, gi), &gate_type) in &replacements { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.update_gate_batch(gi, |gate| { + gate.gate_type = gate_type; + gate.angles.clear(); + gate.params.clear(); + gate.meas_ids.clear(); + gate.channel = None; + }) + .unwrap_or_else(|err| panic!("{err}")); + } + } + + let mut remove_list: Vec<(usize, usize)> = to_remove.into_iter().collect(); + remove_list.sort_unstable(); + for &(ti, gi) in remove_list.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, _circuit: &mut DagCircuit) { + // Tick-only for now: this pass intentionally preserves tick-local + // metadata and rewrites concrete scheduled gate positions. + } +} + /// ASAP-schedule gates to minimise tick count, then drop empty ticks. /// /// For each gate (processed in original tick order), the pass assigns it to @@ -1823,6 +2063,8 @@ mod tests { GateType::SYdg => Some(unitary_rep::SY(q0).dg()), GateType::SZ => Some(unitary_rep::SZ(q0)), GateType::SZdg => Some(unitary_rep::SZ(q0).dg()), + GateType::F => Some(unitary_rep::SZ(q0) * unitary_rep::SX(q0)), + GateType::Fdg => Some(unitary_rep::SX(q0).dg() * unitary_rep::SZ(q0).dg()), GateType::T => Some(unitary_rep::T(q0)), GateType::Tdg => Some(unitary_rep::T(q0).dg()), GateType::RX => { @@ -2885,6 +3127,97 @@ mod tests { assert!(saw_untouched); } + #[test] + fn single_qubit_clifford_canonical_sequences_cover_all_1q() { + for &clifford in Clifford::all_1q() { + let sequence = canonical_single_qubit_clifford_sequence(clifford); + assert_eq!( + single_qubit_clifford_sequence_product(&sequence), + clifford, + "canonical sequence {sequence:?} does not implement {clifford}" + ); + assert!( + sequence.len() <= 2, + "canonical sequence for {clifford} should use at most two gates" + ); + } + } + + #[test] + fn simplify_single_qubit_clifford_chains_reduces_batched_chains() { + let mut original = TickCircuit::new(); + original.tick().sx(&[0, 1]); + original.tick().sz(&[0, 1]); + + let mut simplified = original.clone(); + SimplifySingleQubitCliffordChains.apply_tick(&mut simplified); + + assert_circuits_equiv(&original, &simplified); + let gates: Vec<&Gate> = simplified + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .collect(); + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::F)); + } + + #[test] + fn simplify_single_qubit_clifford_chains_removes_identity_products() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + assert!( + tc.ticks() + .iter() + .all(super::super::tick_circuit::Tick::is_empty) + ); + } + + #[test] + fn simplify_single_qubit_clifford_chains_respects_multi_qubit_barriers() { + let mut tc = TickCircuit::new(); + tc.tick().sx(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().sz(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + let gate_types: Vec = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .map(|gate| gate.gate_type) + .collect(); + assert_eq!(gate_types, vec![GateType::SX, GateType::CX, GateType::SZ]); + } + + #[test] + fn simplify_single_qubit_clifford_chains_preserves_annotated_gates() { + let mut tc = TickCircuit::new(); + tc.tick() + .sx(&[0]) + .meta("role", Attribute::String("calibrated".into())); + tc.tick().sz(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + let gate_types: Vec = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .map(|gate| gate.gate_type) + .collect(); + assert_eq!(gate_types, vec![GateType::SX, GateType::SZ]); + assert_eq!( + tc.get_tick(0).unwrap().get_gate_attr(0, "role"), + Some(&Attribute::String("calibrated".into())) + ); + } + #[test] fn split_batched_tick_commands_preserves_payloads_attrs_and_counters() { let mut tc = TickCircuit::new(); diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index dbfc3ed8b..fc273a14c 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -76,6 +76,9 @@ use crate::dag_circuit::{AnnotationKind, DagCircuit, PauliAnnotation}; use std::fmt; use std::ops::{Deref, Index}; +/// Gate metadata key for explicitly zero-duration physical frame updates. +pub const PHYSICAL_DURATION_META_KEY: &str = "_physical_duration"; + fn meta_json_array(circuit: &TickCircuit, key: &str) -> Result, String> { let Some(attr) = circuit.get_meta(key) else { return Ok(Vec::new()); @@ -615,6 +618,14 @@ impl<'a> GateInstanceRef<'a> { } } +fn gate_batch_has_zero_physical_duration(gate: GateBatchRef<'_>) -> bool { + match gate.get_attr(PHYSICAL_DURATION_META_KEY) { + Some(Attribute::Float(duration)) => *duration == 0.0, + Some(Attribute::Int(duration)) => *duration == 0, + _ => false, + } +} + impl Tick { /// Create a new empty tick. #[must_use] @@ -2229,6 +2240,7 @@ impl TickCircuit { pub fn tracked_pauli(&mut self, mut pauli: pecos_core::PauliString) -> usize { pauli.set_phase(pecos_core::QuarterPhase::PlusOne); let idx = self.annotations.len(); + self.insert_pauli_meta_tick(&pauli); self.annotations.push(PauliAnnotation { pauli, kind: AnnotationKind::TrackedPauli, @@ -2244,6 +2256,13 @@ impl TickCircuit { idx } + /// Insert a `TrackedPauliMeta` batch in its own tick at the current point. + fn insert_pauli_meta_tick(&mut self, pauli: &pecos_core::PauliString) { + let qubits: Vec = pauli.qubits().into_iter().map(QubitId::from).collect(); + let gate = Gate::simple(GateType::TrackedPauliMeta, qubits); + self.tick().add_gate(gate); + } + /// Get all annotations. #[must_use] pub fn annotations(&self) -> &[PauliAnnotation] { @@ -2252,6 +2271,14 @@ impl TickCircuit { // ==================== Idle ==================== + /// Insert Idle gates after each two-qubit gate on both of its qubits. + /// + /// Delegates to `InsertIdleAfterTwoQubitGates` pass. See [`crate::pass`]. + pub fn insert_idle_after_two_qubit_gates(&mut self, duration: f64) { + use crate::pass::{CircuitPass, InsertIdleAfterTwoQubitGates}; + InsertIdleAfterTwoQubitGates(duration).apply_tick(self); + } + /// Insert identity gates for qubits not operated on during each tick. /// /// For each tick, finds qubits that are in the circuit's qubit set but @@ -2262,6 +2289,17 @@ impl TickCircuit { /// This is separate from `GateType::Idle` which represents explicit /// wait operations with duration-dependent `p_idle` noise. /// + /// Metadata-only ticks (all batches `GateType::is_meta`) are zero + /// physical duration and receive no idle gates. + /// + /// # Panics + /// + /// Panics if a tick mixes meta and physical gate batches: such a tick has + /// physical duration, but per-tick qubit exclusivity makes idle insertion + /// on meta-occupied qubits impossible, so the accounting would be + /// silently wrong. Emit meta batches in their own tick (as + /// `tracked_pauli` does). + /// /// # Example /// /// ``` @@ -2274,14 +2312,6 @@ impl TickCircuit { /// /// circuit.fill_idle_gates(); /// ``` - /// Insert Idle gates after each two-qubit gate on both of its qubits. - /// - /// Delegates to `InsertIdleAfterTwoQubitGates` pass. See [`crate::pass`]. - pub fn insert_idle_after_two_qubit_gates(&mut self, duration: f64) { - use crate::pass::{CircuitPass, InsertIdleAfterTwoQubitGates}; - InsertIdleAfterTwoQubitGates(duration).apply_tick(self); - } - pub fn fill_idle_gates(&mut self) { let all_qubits = self.all_qubits(); if all_qubits.is_empty() { @@ -2289,6 +2319,37 @@ impl TickCircuit { } for tick in &mut self.ticks { + // Metadata-only and explicitly zero-duration ticks preserve + // ordering but must not create physical idle periods. + let batch_count = tick.gate_batches().len(); + let meta_batches = tick + .iter_gate_batches() + .filter(|gate| gate.as_gate().gate_type.is_meta()) + .count(); + let zero_duration_batches = tick + .iter_gate_batches() + .filter(|gate| gate_batch_has_zero_physical_duration(*gate)) + .count(); + if !tick.is_empty() && meta_batches + zero_duration_batches == batch_count { + continue; + } + // A tick that mixes meta and physical gates has ambiguous idle + // accounting: the tick has physical duration, but per-tick qubit + // exclusivity makes it impossible to insert an Idle on a qubit a + // meta gate already occupies. Meta batches belong in their own + // tick (as `tracked_pauli` emits them). + assert!( + meta_batches == 0, + "fill_idle_gates: tick mixes meta and physical gates; emit meta \ + batches in their own tick so idle-duration accounting stays \ + unambiguous" + ); + assert!( + zero_duration_batches == 0, + "fill_idle_gates: tick mixes zero-duration and physical gates; emit \ + zero-duration frame updates in their own tick so idle-duration \ + accounting stays unambiguous" + ); let active = tick.active_qubits(); for &q in &all_qubits { if !active.contains(&q) { @@ -3395,11 +3456,16 @@ impl From<&TickCircuit> for DagCircuit { } AnnotationKind::TrackedPauli => AnnotationKind::TrackedPauli, }; - dag.add_annotation(PauliAnnotation { + let remapped_annotation = PauliAnnotation { pauli: ann.pauli.clone(), kind: remapped_kind, label: ann.label.clone(), - }); + }; + if matches!(remapped_annotation.kind, AnnotationKind::TrackedPauli) { + dag.add_annotation_without_meta_gate(remapped_annotation); + } else { + dag.add_annotation(remapped_annotation); + } } dag @@ -6126,6 +6192,79 @@ mod tests { assert!(count_after > count_before, "Should have added idle gates"); } + #[test] + fn test_fill_idle_gates_skips_tracked_pauli_meta_ticks() { + use pecos_core::pauli::X; + + let mut tc = TickCircuit::new(); + tc.tick().h(&[0, 1]); + tc.tracked_pauli_labeled("frame_marker", X(0)); + tc.tick().h(&[0, 1]); + + tc.fill_idle_gates(); + + let meta_tick = tc + .ticks() + .iter() + .find(|tick| { + tick.gate_batches() + .iter() + .any(|gate| gate.gate_type == GateType::TrackedPauliMeta) + }) + .expect("tracked-Pauli meta tick"); + + assert_eq!(meta_tick.len(), 1); + assert!(meta_tick.gate_batches()[0].gate_type.is_meta()); + } + + #[test] + fn test_fill_idle_gates_skips_zero_duration_ticks() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0, 1]); + tc.tick() + .z(&[0]) + .meta(PHYSICAL_DURATION_META_KEY, Attribute::Float(0.0)); + tc.tick().h(&[0, 1]); + + tc.fill_idle_gates(); + + let zero_duration_tick = tc.get_tick(1).expect("zero-duration tick"); + assert_eq!(zero_duration_tick.gate_count(), 1); + assert_eq!(zero_duration_tick.gate_batches()[0].gate_type, GateType::Z); + assert_eq!( + zero_duration_tick.get_gate_attr(0, PHYSICAL_DURATION_META_KEY), + Some(&Attribute::Float(0.0)) + ); + } + + #[test] + #[should_panic(expected = "tick mixes meta and physical gates")] + fn test_fill_idle_gates_rejects_mixed_meta_and_physical_tick() { + let mut tc = TickCircuit::new(); + let mut tick = tc.tick(); + tick.h(&[0]); + tick.try_add_gate(Gate::simple( + GateType::TrackedPauliMeta, + vec![QubitId::from(1)], + )) + .map(|_| ()) + .expect("meta gate on a free qubit must be addable"); + + tc.fill_idle_gates(); + } + + #[test] + #[should_panic(expected = "tick mixes zero-duration and physical gates")] + fn test_fill_idle_gates_rejects_mixed_zero_duration_and_physical_tick() { + let mut tc = TickCircuit::new(); + tc.tick() + .h(&[0]) + .z(&[1]) + .meta(PHYSICAL_DURATION_META_KEY, Attribute::Float(0.0)); + + tc.fill_idle_gates(); + } + #[test] fn test_channel_gate_is_first_class_tick_operation() { let mut tc = TickCircuit::new(); diff --git a/crates/pecos-results/Cargo.toml b/crates/pecos-results/Cargo.toml new file mode 100644 index 000000000..ce8cb266c --- /dev/null +++ b/crates/pecos-results/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pecos-results" +version.workspace = true +edition.workspace = true +readme = "README.md" +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Result types (Shot, ShotVec, ShotMap, Data) for PECOS quantum program execution" + +[dependencies] +pecos-core.workspace = true +serde.workspace = true +serde_json.workspace = true +num-bigint.workspace = true +bitvec.workspace = true + +[lints] +workspace = true diff --git a/crates/pecos-results/README.md b/crates/pecos-results/README.md new file mode 100644 index 000000000..e6f8972ca --- /dev/null +++ b/crates/pecos-results/README.md @@ -0,0 +1,13 @@ +# pecos-results + +Result types for PECOS quantum program execution: `Shot`, `ShotVec`, +`ShotMap`, `Data`, and `DataVec`. + +These types are the shared result contract between PECOS simulation stacks +(`pecos-engines`, `pecos-neo`) and their language bindings. They carry named +registers with flexible values (integers, floats, bit vectors, JSON) in +row-based (`ShotVec`) or columnar (`ShotMap`) form, with conversions between +the two and display/export utilities. + +This crate is deliberately free of any execution-protocol or simulator +dependencies so that any producer can emit results in this format. diff --git a/crates/pecos-engines/src/shot_results/conversions.rs b/crates/pecos-results/src/conversions.rs similarity index 96% rename from crates/pecos-engines/src/shot_results/conversions.rs rename to crates/pecos-results/src/conversions.rs index 2807963ef..4c64d51e8 100644 --- a/crates/pecos-engines/src/shot_results/conversions.rs +++ b/crates/pecos-results/src/conversions.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-engines/src/shot_results/data.rs b/crates/pecos-results/src/data.rs similarity index 99% rename from crates/pecos-engines/src/shot_results/data.rs rename to crates/pecos-results/src/data.rs index 1f0e5434e..13ac6f233 100644 --- a/crates/pecos-engines/src/shot_results/data.rs +++ b/crates/pecos-results/src/data.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-engines/src/shot_results/data_vec.rs b/crates/pecos-results/src/data_vec.rs similarity index 99% rename from crates/pecos-engines/src/shot_results/data_vec.rs rename to crates/pecos-results/src/data_vec.rs index ece957d8e..291f3b104 100644 --- a/crates/pecos-engines/src/shot_results/data_vec.rs +++ b/crates/pecos-results/src/data_vec.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at @@ -26,7 +26,7 @@ use serde_json::Value as JsonValue; /// /// # Example /// ``` -/// use pecos_engines::{DataVec, Data}; +/// use pecos_results::{Data, DataVec}; /// /// // Create a DataVec from a vector of Data values /// let data_values = vec![Data::U32(1), Data::U32(2), Data::U32(3)]; @@ -292,7 +292,7 @@ impl DataVec { /// /// # Example /// ``` - /// use pecos_engines::{DataVec, DataVecType}; + /// use pecos_results::{DataVec, DataVecType}; /// /// let vec = DataVec::new_empty(DataVecType::U32); /// assert!(vec.is_empty()); diff --git a/crates/pecos-results/src/lib.rs b/crates/pecos-results/src/lib.rs new file mode 100644 index 000000000..a2b4272a4 --- /dev/null +++ b/crates/pecos-results/src/lib.rs @@ -0,0 +1,202 @@ +// 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 results and data structures for quantum program execution. +//! +//! This module provides comprehensive data structures for storing and manipulating +//! the results of quantum program executions. It includes: +//! +//! - **Data Types**: The `Data` enum for flexible value storage +//! - **Single Results**: The `Shot` struct for individual execution results +//! - **Collections**: The `ShotVec` struct for multiple executions +//! - **Columnar Analysis**: The `ShotMap` struct for efficient analysis +//! - **Formatting**: Display and export utilities +//! +//! # Design Philosophy +//! +//! The module is designed around the following principles: +//! - **Flexibility**: Support for diverse data types and quantum backends +//! - **Efficiency**: Optimized for common operations like analysis and export +//! - **Compatibility**: Easy conversion between row-based and columnar formats +//! - **Extensibility**: JSON support for custom and complex data +//! +//! # Main Types +//! +//! ## `Data` - Flexible Value Storage +//! ``` +//! use pecos_results::Data; +//! use bitvec::prelude::*; +//! +//! // Support for various numeric types +//! let measurement = Data::U32(42); +//! let phase = Data::F64(3.14159); +//! +//! // BitVec for quantum register results +//! let mut bits = BitVec::::new(); +//! bits.push(true); +//! bits.push(false); +//! let register = Data::BitVec(bits); +//! ``` +//! +//! ## `Shot` - Single Execution Results +//! ``` +//! use pecos_results::{Shot, Data}; +//! +//! let mut shot = Shot::default(); +//! shot.add_register("qubits", 5, 3); // 3-bit register with value 5 +//! shot.data.insert("error_rate".to_string(), Data::F64(0.001)); +//! ``` +//! +//! ## `ShotVec` - Multiple Executions +//! ``` +//! use pecos_results::{ShotVec, Shot}; +//! +//! let mut results = ShotVec::new(); +//! for i in 0..100 { +//! let mut shot = Shot::default(); +//! shot.add_register("measurement", i % 8, 3); +//! results.shots.push(shot); +//! } +//! +//! // Convert to JSON for export +//! let json = results.to_compact_json(); +//! ``` +//! +//! ## `ShotMap` - Columnar Analysis +//! ``` +//! # use pecos_results::{ShotVec, Shot}; +//! # let mut results = ShotVec::new(); +//! # for i in 0..100 { +//! # let mut shot = Shot::default(); +//! # shot.add_register("measurement", i % 8, 3); +//! # results.shots.push(shot); +//! # } +//! // Convert to columnar format for analysis +//! let shot_map = results.try_as_shot_map().unwrap(); +//! +//! // Efficient analysis of specific registers +//! let measurements = shot_map.try_bits_as_u64("measurement").unwrap(); +//! let average: f64 = measurements.iter().sum::() as f64 / measurements.len() as f64; +//! ``` + +#![allow(clippy::similar_names)] +// For percentage calculations below with large usize values converted to f64, +// we accept the potential precision loss since the values are used only for display +// with a single decimal place, and the precision loss would only be observable +// with extremely large shot counts (> 2^53). +#![allow(clippy::cast_precision_loss)] + +// Sub-modules +pub mod conversions; +pub mod data; +pub mod data_vec; +pub mod shot; +pub mod shot_map; +pub mod shot_map_formatter; +#[cfg(test)] +mod shot_tests; +pub mod shot_vec; + +// Re-export all public types for backward compatibility +pub use data::Data; +pub use data_vec::{DataVec, DataVecType}; +pub use shot::Shot; +pub use shot_map::ShotMap; +pub use shot_map_formatter::{ + BitVecDisplayFormat, ShotMapDisplay, ShotMapDisplayExt, ShotMapDisplayOptions, +}; +pub use shot_vec::ShotVec; + +// Re-export for tests and benchmarks that may reference the full module path +#[cfg(test)] +#[allow(clippy::similar_names)] +mod tests { + use super::*; + + #[test] + fn test_shot_results_display_64bit() { + // Create a shot with various data types + let mut shot1 = Shot::default(); + shot1.data.insert("reg_32".to_string(), Data::U32(42)); + + // Add a large 64-bit register (larger than u32::MAX) + let large_value = 1u64 << 34; // 2^34 = 17,179,869,184 (>4B) + shot1 + .data + .insert("reg_64".to_string(), Data::U64(large_value)); + + // Add a signed 64-bit register with negative value + shot1.data.insert("reg_signed".to_string(), Data::I64(-42)); + + // Add some floating point data + shot1 + .data + .insert("float_val".to_string(), Data::F64(std::f64::consts::PI)); + + // Create ShotVec with one shot + let shot_results = ShotVec { shots: vec![shot1] }; + + // Convert to string + let json_string = shot_results.to_compact_json(); + let display_string = format!("{shot_results}"); + + // The display string should match the compact JSON string + assert_eq!(display_string, json_string); + + // Verify that both are valid JSON and contain the same data + let json_value1: serde_json::Value = serde_json::from_str(&display_string).unwrap(); + let json_value2: serde_json::Value = serde_json::from_str(&json_string).unwrap(); + + // Verify that both are arrays with the same length + assert_eq!( + json_value1.as_array().unwrap().len(), + json_value2.as_array().unwrap().len(), + "JSON arrays should have the same number of shots" + ); + + // Verify that all registers appear in the JSON + assert!(json_string.contains("\"reg_32\"")); + assert!(json_string.contains("42")); + assert!(json_string.contains("\"reg_64\"")); + assert!(json_string.contains("17179869184")); + assert!(json_string.contains("\"reg_signed\"")); + assert!(json_string.contains("-42")); + assert!(json_string.contains("\"float_val\"")); + assert!(json_string.contains("3.14159")); + } + + #[test] + fn test_module_integration() { + // Test that all modules work together correctly + let mut shot_vec = ShotVec::new(); + + for i in 0..5 { + let mut shot = Shot::default(); + shot.add_register("qubits", i, 3); + shot.data + .insert("phase".to_string(), Data::F64(f64::from(i) * 0.1)); + shot_vec.shots.push(shot); + } + + // Convert to ShotMap + let shot_map = shot_vec.try_as_shot_map().unwrap(); + + // Test data access + assert_eq!(shot_map.num_shots(), 5); + assert_eq!(shot_map.num_registers(), 2); // qubits + phase (width metadata filtered out) + + // Test formatting + let display_output = format!("{}", shot_map.display()); + assert!(display_output.contains("\"qubits\"")); + assert!(display_output.contains("\"phase\"")); + } +} diff --git a/crates/pecos-engines/src/shot_results/shot.rs b/crates/pecos-results/src/shot.rs similarity index 75% rename from crates/pecos-engines/src/shot_results/shot.rs rename to crates/pecos-results/src/shot.rs index 8e1909293..705e287c4 100644 --- a/crates/pecos-engines/src/shot_results/shot.rs +++ b/crates/pecos-results/src/shot.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at @@ -11,9 +11,7 @@ // the License. use super::data::Data; -use crate::byte_message::ByteMessage; use bitvec::prelude::*; -use pecos_core::errors::PecosError; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -95,50 +93,6 @@ impl Shot { } } - /// Create a `Shot` directly from a `ByteMessage` containing measurement results. - /// - /// This method extracts measurement results from a `ByteMessage` and creates a `Shot` - /// with properly mapped result IDs to names. - /// - /// # Parameters - /// - /// * `message` - A `ByteMessage` containing measurement results - /// * `result_id_to_name` - A mapping from `result_id` to a human-readable name - /// - /// # Returns - /// - /// A new `Shot` instance containing the processed measurement results - /// - /// # Errors - /// - /// Returns an error if the `ByteMessage` cannot be parsed or doesn't contain valid measurement results - pub fn from_byte_message( - message: &ByteMessage, - result_id_to_name: &BTreeMap, - ) -> Result { - // Extract the raw measurement results from the ByteMessage - let outcomes = message.outcomes()?; - - // Convert raw outcomes to indexed results - let measurements: Vec<(usize, u32)> = outcomes.into_iter().enumerate().collect(); - - let mut result = Self::default(); - - // Process each measurement - for (result_id, value) in measurements { - // Get the name for this result_id, or use a default if not found - let name = result_id_to_name - .get(&result_id) - .cloned() - .unwrap_or_else(|| format!("result_{result_id}")); - - // Store as U32 data - result.data.insert(name, Data::U32(value)); - } - - Ok(result) - } - /// Creates a binary string representation of results. /// /// This is a convenience method that creates a binary string from register values. diff --git a/crates/pecos-engines/src/shot_results/shot_map.rs b/crates/pecos-results/src/shot_map.rs similarity index 97% rename from crates/pecos-engines/src/shot_results/shot_map.rs rename to crates/pecos-results/src/shot_map.rs index 683730443..79eb50c8d 100644 --- a/crates/pecos-engines/src/shot_results/shot_map.rs +++ b/crates/pecos-results/src/shot_map.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at @@ -29,8 +29,8 @@ use std::fmt; /// /// # Example /// ``` -/// # use pecos_engines::shot_results::{ShotVec, Shot, Data}; -/// # use pecos_engines::{ShotMapDisplayExt, BitVecDisplayFormat}; +/// # use pecos_results::{ShotVec, Shot, Data}; +/// # use pecos_results::{BitVecDisplayFormat, ShotMapDisplayExt}; /// # use pecos_core::errors::PecosError; /// # fn main() -> Result<(), PecosError> { /// let mut shot_vec = ShotVec::new(); @@ -165,7 +165,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::{ShotVec, Shot, Data, ShotMap}; + /// # use pecos_results::{Data, Shot, ShotMap, ShotVec}; /// # use pecos_core::errors::PecosError; /// # fn main() -> Result<(), PecosError> { /// let mut shot_vec = ShotVec::new(); @@ -248,7 +248,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use bitvec::prelude::*; /// # fn main() -> Result<(), PecosError> { @@ -309,7 +309,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use bitvec::prelude::*; /// # fn main() -> Result<(), PecosError> { @@ -366,7 +366,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use num_bigint::BigUint; /// # fn main() -> Result<(), PecosError> { @@ -428,7 +428,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use bitvec::prelude::*; /// # fn main() -> Result<(), PecosError> { @@ -729,7 +729,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use bitvec::prelude::*; /// # fn main() -> Result<(), PecosError> { @@ -798,7 +798,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot, Data}; + /// # use pecos_results::{ShotVec, Shot, Data}; /// # use pecos_core::errors::PecosError; /// # fn main() -> Result<(), PecosError> { /// let mut shot_vec = ShotVec::new(); @@ -832,7 +832,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # fn main() -> Result<(), PecosError> { /// let mut shot_vec = ShotVec::new(); @@ -886,7 +886,7 @@ impl ShotMap { impl fmt::Display for ShotMap { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Import the extension trait to get the display() method - use crate::shot_results::shot_map_formatter::ShotMapDisplayExt; + use crate::shot_map_formatter::ShotMapDisplayExt; // Delegate to the display formatter write!(f, "{}", self.display()) } @@ -915,7 +915,7 @@ impl<'a> IntoIterator for &'a ShotMap { #[cfg(test)] mod tests { use super::*; - use crate::shot_results::{Shot, ShotVec}; + use crate::{Shot, ShotVec}; #[test] fn test_shot_map_creation() { @@ -938,7 +938,7 @@ mod tests { #[test] fn test_display_impl() { - use crate::shot_results::shot_map_formatter::ShotMapDisplayExt; + use crate::shot_map_formatter::ShotMapDisplayExt; let mut shot_vec = ShotVec::new(); diff --git a/crates/pecos-engines/src/shot_results/shot_map_formatter.rs b/crates/pecos-results/src/shot_map_formatter.rs similarity index 99% rename from crates/pecos-engines/src/shot_results/shot_map_formatter.rs rename to crates/pecos-results/src/shot_map_formatter.rs index f4e65bae7..1020c11af 100644 --- a/crates/pecos-engines/src/shot_results/shot_map_formatter.rs +++ b/crates/pecos-results/src/shot_map_formatter.rs @@ -245,7 +245,7 @@ impl ShotMapDisplayExt for ShotMap { #[cfg(test)] mod tests { use super::*; - use crate::shot_results::{Data, Shot, ShotVec}; + use crate::{Data, Shot, ShotVec}; #[test] fn test_display_formatting() { diff --git a/crates/pecos-engines/src/shot_results/shot_tests.rs b/crates/pecos-results/src/shot_tests.rs similarity index 99% rename from crates/pecos-engines/src/shot_results/shot_tests.rs rename to crates/pecos-results/src/shot_tests.rs index 4e6ac2676..bc874c445 100644 --- a/crates/pecos-engines/src/shot_results/shot_tests.rs +++ b/crates/pecos-results/src/shot_tests.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at @@ -16,7 +16,7 @@ #[cfg(test)] mod tests { - use crate::shot_results::{Data, Shot, ShotVec}; + use crate::{Data, Shot, ShotVec}; #[test] fn test_shot_results_display_64bit() { diff --git a/crates/pecos-engines/src/shot_results/shot_vec.rs b/crates/pecos-results/src/shot_vec.rs similarity index 85% rename from crates/pecos-engines/src/shot_results/shot_vec.rs rename to crates/pecos-results/src/shot_vec.rs index 4c793fd03..4d24592d7 100644 --- a/crates/pecos-engines/src/shot_results/shot_vec.rs +++ b/crates/pecos-results/src/shot_vec.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at @@ -11,7 +11,6 @@ // the License. use super::{data::Data, shot::Shot}; -use crate::byte_message::ByteMessage; use pecos_core::errors::PecosError; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -87,7 +86,7 @@ impl ShotVec { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// let mut shot_vec = ShotVec::new(); /// /// // Add shots with consistent structure @@ -282,44 +281,6 @@ impl ShotVec { } } - /// Create a `ShotVec` instance directly from a `ByteMessage` containing measurement results. - /// - /// This method extracts measurement results from a `ByteMessage` and creates a `ShotVec` - /// instance with properly formatted results. It's more efficient than going through - /// `Shot` instances and provides better context about the measurements. - /// - /// # Parameters - /// - /// * `message` - A `ByteMessage` containing measurement results - /// - /// # Errors - /// - /// Returns a `PecosError` if the measurements cannot be extracted from the `ByteMessage` - /// or if there are issues with creating the `ShotVec` instance. - pub fn from_byte_message(message: &ByteMessage) -> Result { - // Extract the measurement results from the ByteMessage - // Extract raw measurement outcomes - let outcomes = message.outcomes()?; - - // Convert to indexed measurements - let measurements: Vec<(usize, u32)> = outcomes.into_iter().enumerate().collect(); - - let mut shot_result = Shot::default(); - - // Process each measurement - for (result_id, value) in measurements { - // Get the name for this result_id, or use a default if not found - let name = format!("result_{result_id}"); - - // Add the measurement to the results - shot_result.data.insert(name, Data::U32(value)); - } - - Ok(Self { - shots: vec![shot_result], - }) - } - /// Prints the `ShotVec` to stdout. pub fn print(&self) { println!("{self}"); diff --git a/crates/pecos-selene-core/Cargo.toml b/crates/pecos-selene-core/Cargo.toml index 1ce97c9ea..502bb0439 100644 --- a/crates/pecos-selene-core/Cargo.toml +++ b/crates/pecos-selene-core/Cargo.toml @@ -16,7 +16,7 @@ crate-type = ["rlib"] anyhow.workspace = true pecos-core.workspace = true pecos-simulators.workspace = true -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [dev-dependencies] pecos-simulators.workspace = true diff --git a/crates/pecos-tesseract/src/decoder.rs b/crates/pecos-tesseract/src/decoder.rs index 4962d2163..517f4d8fd 100644 --- a/crates/pecos-tesseract/src/decoder.rs +++ b/crates/pecos-tesseract/src/decoder.rs @@ -185,6 +185,18 @@ impl TesseractDecoder { let num_errors = ffi::get_num_errors(&inner); let num_observables = ffi::get_num_observables(&inner); + // Tesseract reports its predicted observables as a u64 mask + // (`DecodingResult::observables_mask`), so it supports at most 64 + // observables. Reject wider DEMs as an error rather than silently + // truncating observables 64.. into the u64. + if num_observables > 64 { + return Err(TesseractError::InvalidConfig(format!( + "this matching decoder packs observables into a u64 and supports at most 64 \ + observables, but the DEM has {num_observables}; use the 'pymatching' decoder \ + or LogicalSubgraphDecoder for wider observable sets" + ))); + } + Ok(Self { inner, config, @@ -350,10 +362,10 @@ impl TesseractDecoder { } impl pecos_decoder_core::ObservableDecoder for TesseractDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { let detections: Vec = syndrome .iter() .enumerate() @@ -363,7 +375,9 @@ impl pecos_decoder_core::ObservableDecoder for TesseractDecoder { let result = self .decode_detections(&det_arr.view()) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; - Ok(result.observables_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + result.observables_mask, + )) } } diff --git a/crates/pecos-uf-decoder/examples/profile_decode.rs b/crates/pecos-uf-decoder/examples/profile_decode.rs index 78bcd54fa..7bcd30ff5 100644 --- a/crates/pecos-uf-decoder/examples/profile_decode.rs +++ b/crates/pecos-uf-decoder/examples/profile_decode.rs @@ -16,7 +16,7 @@ fn shots_as_f64(num_shots: usize) -> f64 { fn profile_decoder(name: &str, dem: &str, num_shots: usize) { let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()).unwrap(); let num_det = graph.num_detectors; // Generate random syndromes @@ -59,7 +59,7 @@ fn profile_phases(name: &str, dem: &str, num_shots: usize) { .collect(); // Phase 1: measure reset + syndrome loading only - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()).unwrap(); let t0 = Instant::now(); for syn in &syndromes { dec.syndrome_validate(syn); // reset + grow (no peel) @@ -126,7 +126,7 @@ fn main() { println!(); println!("=== Balanced (Prim MST) ==="); let graph = DemMatchingGraph::from_dem_str(D5_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()).unwrap(); let num_det = graph.num_detectors; let mut rng = fastrand::Rng::with_seed(42); diff --git a/crates/pecos-uf-decoder/src/astar.rs b/crates/pecos-uf-decoder/src/astar.rs index 1b0b56bc8..24238b78b 100644 --- a/crates/pecos-uf-decoder/src/astar.rs +++ b/crates/pecos-uf-decoder/src/astar.rs @@ -135,6 +135,7 @@ impl AStarDecoder { /// Returns `DecoderError` if the DEM is malformed. pub fn from_dem(dem: &str, config: AStarConfig) -> Result { let graph = DemMatchingGraph::from_dem_str(dem)?; + graph.ensure_observables_fit_u64()?; let num_detectors = graph.num_detectors; let mut mechanisms = Vec::new(); @@ -189,6 +190,7 @@ impl AStarDecoder { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| DecoderError::InvalidGraph(e.to_string()))?; + dcm.ensure_observables_fit_u64()?; let num_detectors = dcm.num_detectors; let mut mechanisms = Vec::new(); @@ -306,7 +308,10 @@ impl AStarDecoder { } impl ObservableDecoder for AStarDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let n = self.num_detectors; let m = self.num_mechanisms; @@ -319,7 +324,7 @@ impl ObservableDecoder for AStarDecoder { } let num_defects = init_residual.count_ones(); if num_defects == 0 { - return Ok(0); + return Ok(pecos_decoder_core::obs_mask::ObsMask::new()); } // A* priority queue and visited set. @@ -462,7 +467,7 @@ impl ObservableDecoder for AStarDecoder { } } - Ok(best_obs) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(best_obs)) } } diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs index 0a293ce40..4ba969e84 100644 --- a/crates/pecos-uf-decoder/src/bp_uf.rs +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -151,7 +151,9 @@ impl BpUfDecoder { 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); + graph.ensure_observables_fit_u64()?; + UfDecoder::check_non_negative_weights(&graph)?; + let uf = UfDecoder::from_matching_graph(&graph, config.uf_config)?; // Build mechanism → edge mapping. // Each mechanism in the check matrix corresponds to a column. @@ -252,7 +254,9 @@ impl BpUfDecoder { // 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); + match_graph.ensure_observables_fit_u64()?; + UfDecoder::check_non_negative_weights(&match_graph)?; + let uf = UfDecoder::from_matching_graph(&match_graph, config.uf_config)?; // Map BP mechanisms (non-decomposed) → matching graph edges (decomposed). let mut mechanism_to_edge = vec![None; bp_dcm.num_mechanisms]; @@ -453,16 +457,28 @@ impl pecos_decoder_core::bp_matching::BpWeightProvider for BpUfDecoder { } fn is_trivial(&self, syndrome: &[u8]) -> Option { + if !self.uf.config.predecoder { + return None; + } self.uf.predecode_clusters(syndrome) } } impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { // Fast path: cluster predecoder handles isolated cases without BP. - // This catches 0 defects, single defects, and isolated pairs. - if let Some(obs) = self.uf.predecode_clusters(syndrome) { - return Ok(obs); + // This catches 0 defects, single defects, and isolated pairs. Gated on + // the UF config like the plain `UfDecoder` paths. It deliberately runs + // on construction-time weights, bypassing BP: the cases it accepts are + // provably min-weight under the prior weights, and BP reweighting is + // only consulted for the larger clusters that fall through. + if self.uf.config.predecoder + && let Some(obs) = self.uf.predecode_clusters(syndrome) + { + return Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs)); } let num_defects = syndrome.iter().filter(|&&v| v != 0).count(); @@ -560,10 +576,10 @@ impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { let (mask2, _) = self .uf .decode_with_weights(syndrome, &self.adjusted_weights)?; - return Ok(mask2); + return Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(mask2)); } - Ok(mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(mask)) } } diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs index 69baaf239..4658cb382 100644 --- a/crates/pecos-uf-decoder/src/css_decoder.rs +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -113,13 +113,17 @@ impl CssUfDecoder { ) -> Result { let x_graph = DemMatchingGraph::from_dem_str(x_dem)?; let z_graph = DemMatchingGraph::from_dem_str(z_dem)?; + x_graph.ensure_observables_fit_u64()?; + z_graph.ensure_observables_fit_u64()?; + UfDecoder::check_non_negative_weights(&x_graph)?; + UfDecoder::check_non_negative_weights(&z_graph)?; // 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); + let x_decoder = UfDecoder::from_matching_graph(&x_graph, config)?; + let z_decoder = UfDecoder::from_matching_graph(&z_graph, config)?; Ok(Self { x_decoder, @@ -295,7 +299,10 @@ impl pecos_decoder_core::ObservableDecoder for CssUfDecoder { /// /// The syndrome is split at `x_num_detectors` into X and Z parts. /// Returns the XOR of both observable masks. - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let split = self.x_num_detectors; if syndrome.len() < split { return Err(DecoderError::DecodingFailed(format!( @@ -307,7 +314,9 @@ impl pecos_decoder_core::ObservableDecoder for CssUfDecoder { let x_syn = &syndrome[..split]; let z_syn = &syndrome[split..]; let (x_obs, z_obs) = self.decode_css(x_syn, z_syn)?; - Ok(x_obs ^ z_obs) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + x_obs ^ z_obs, + )) } } diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 1ce43de0d..53ce80700 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -23,7 +23,12 @@ //! 5. Peel a spanning forest (BFS from boundary) to extract the correction: //! an edge is in the correction iff its subtree has odd parity. //! -//! All data structures are flat arrays. Zero per-shot allocation after init. +//! All data structures are flat arrays; the full grow+peel decode path does zero +//! per-shot allocation after init. The optional cluster predecoder +//! ([`UfDecoder::predecode_clusters`]) is the exception: it runs on `&self` +//! (before the per-shot state is reset) and allocates two small scratch buffers +//! per call. It is only entered for trivial syndromes (0-2 isolated defects), +//! falling through to the zero-alloc path otherwise. use pecos_decoder_core::correlated_decoder::MatchingDecoder; use pecos_decoder_core::dem::DemMatchingGraph; @@ -144,8 +149,9 @@ pub struct UfDecoder { adj_offset: Vec, /// Number of detectors. num_detectors: usize, - /// Config. - config: UfDecoderConfig, + /// Config. Crate-visible so wrappers (e.g. `BpUfDecoder`) honour the same + /// flags (notably `predecoder`) instead of silently ignoring them. + pub(crate) config: UfDecoderConfig, // === Per-shot reusable buffers === /// Disjoint-set forest: parent[i] = parent of node i. @@ -193,9 +199,58 @@ impl UfDecoder { &self.adj_data[start..end] } + /// Check that every edge weight in `graph` is non-negative. + /// + /// The predecoder's shortcut proofs ("lightest edge is the min-weight + /// correction", "direct pair <= boundary split") require non-negative + /// weights. `ln((1-p)/p)` guarantees that for priors p <= 0.5, but a raw + /// `error(p)` DEM line with p > 0.5 produces a negative weight. Call this + /// at any boundary where DEM text enters, so bad input is rejected as an + /// error instead of panicking in `from_matching_graph`. + /// + /// # Errors + /// + /// Returns `DecoderError::InvalidConfiguration` naming the first offending + /// edge (negative or NaN weight). + pub fn check_non_negative_weights(graph: &DemMatchingGraph) -> Result<(), DecoderError> { + if let Some((idx, e)) = graph + .edges + .iter() + .enumerate() + .find(|(_, e)| e.weight < 0.0 || e.weight.is_nan()) + { + return Err(DecoderError::InvalidConfiguration(format!( + "UfDecoder requires non-negative edge weights (error priors p <= 0.5), \ + but edge {idx} (node {} -- {:?}) has weight {}", + e.node1, e.node2, e.weight, + ))); + } + Ok(()) + } + /// Build from a `DemMatchingGraph`. - #[must_use] - pub fn from_matching_graph(graph: &DemMatchingGraph, config: UfDecoderConfig) -> Self { + /// + /// # Errors + /// + /// Returns [`DecoderError`] if the matching graph carries more than 64 + /// observables: this decoder packs observable flips into a `u64`, so wider + /// observable sets are rejected rather than silently truncated. + /// + /// # Panics + /// + /// Panics if any edge weight is negative or NaN: the predecoder's + /// optimality proofs depend on non-negative weights, so violating that + /// premise must fail loudly rather than silently mis-decode. Callers + /// holding untrusted DEM input should pre-validate with + /// [`Self::check_non_negative_weights`] to get an error instead. + pub fn from_matching_graph( + graph: &DemMatchingGraph, + config: UfDecoderConfig, + ) -> Result { + // Fail loud rather than overflow-panic at the `1 << o` packing below: this + // decoder packs observable flips into a u64 and supports at most 64. + graph.ensure_observables_fit_u64()?; + let num_detectors = graph.num_detectors; let num_nodes = num_detectors + 1; let boundary_node = num_detectors as u32; @@ -224,6 +279,17 @@ impl UfDecoder { temp_adj[n2 as usize].push((idx, n1)); } + // Construction-time invariant: edge weights are non-negative (true for + // `ln((1-p)/p)` when p <= 0.5, i.e. real sub-threshold priors). The + // predecoder's shortcut proofs ("lightest edge is the min-weight + // correction", "direct pair <= boundary split") depend on it; a negative + // weight would silently break them, so this is a hard assert (a raw + // `error(p > 0.5)` DEM line violates it). Checked once here, not per shot. + assert!( + edges.iter().all(|e| e.weight >= 0.0), + "UfDecoder requires non-negative edge weights (error priors p <= 0.5)" + ); + // Sort each node's adjacency by weight (lightest first). for adj in &mut temp_adj { adj.sort_by(|a, b| { @@ -244,7 +310,7 @@ impl UfDecoder { } adj_offset.push(adj_data.len() as u32); - Self { + Ok(Self { edges, adj_data, adj_offset, @@ -266,17 +332,21 @@ impl UfDecoder { 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. + /// Returns `DecoderError` if the DEM is malformed or contains an error + /// prior p > 0.5 (negative edge weight, which the predecoder's optimality + /// proofs do not admit). pub fn from_dem(dem: &str, config: UfDecoderConfig) -> Result { let graph = DemMatchingGraph::from_dem_str(dem)?; - Ok(Self::from_matching_graph(&graph, config)) + Self::check_non_negative_weights(&graph)?; + // `from_matching_graph` performs the >64-observable guard. + Self::from_matching_graph(&graph, config) } /// Reset per-shot state. Uses bulk fill operations for cache efficiency. @@ -456,8 +526,9 @@ impl UfDecoder { let root = component[di]; if comp_size[root] == 1 { - // Isolated defect: match to boundary. - obs_mask ^= self.predecode_single(defect_list[di]); + // Isolated defect: provably optimal only if its lightest edge + // is a direct boundary edge; otherwise fall through (`?`). + obs_mask ^= self.predecode_single(defect_list[di])?; handled[di] = true; } else if comp_size[root] == 2 { // Find the other defect in this component. @@ -473,61 +544,56 @@ impl UfDecoder { let d0 = defect_list[di]; let d1 = defect_list[ni]; - // Find lightest direct edge and lightest boundary alternatives. - let mut direct_w = f64::INFINITY; - let mut direct_obs = 0u64; - for &(e, nbr) in self.adj(d0 as usize) { - if nbr == d1 && self.edges[e].weight < direct_w { - direct_w = self.edges[e].weight; - direct_obs = self.edges[e].obs_mask; + // Pairing the two defects directly is provably the global + // minimum-weight correction ONLY when the shared edge is the + // lightest incident edge of BOTH defects (a "mutually nearest" + // adjacent pair -- the signature of a single bulk fault): + // - any d0-d1 path costs >= each defect's lightest edge, so + // the direct edge is the cheapest pairing, and + // - any split to the boundary costs >= lb0 + lb1 = 2*direct_w + // > direct_w. + // In every other case the boundary route (possibly a + // logical-flipping bulk path) may be cheaper, so we fall through + // to the full decoder rather than guess from direct edges alone. + let e0 = self.adj(d0 as usize).first().copied(); + let e1 = self.adj(d1 as usize).first().copied(); + match (e0, e1) { + (Some((edge_idx, nbr0)), Some((_, nbr1))) if nbr0 == d1 && nbr1 == d0 => { + obs_mask ^= self.edges[edge_idx].obs_mask; + handled[di] = true; + handled[ni] = true; } + _ => return None, } - - let mut b0_w = f64::INFINITY; - let mut b0_obs = 0u64; - for &(e, nbr) in self.adj(d0 as usize) { - if nbr == boundary && self.edges[e].weight < b0_w { - b0_w = self.edges[e].weight; - b0_obs = self.edges[e].obs_mask; - } - } - - let mut b1_w = f64::INFINITY; - let mut b1_obs = 0u64; - for &(e, nbr) in self.adj(d1 as usize) { - if nbr == boundary && self.edges[e].weight < b1_w { - b1_w = self.edges[e].weight; - b1_obs = self.edges[e].obs_mask; - } - } - - // Pick min-weight correction. - if direct_w <= b0_w + b1_w { - obs_mask ^= direct_obs; - } else { - obs_mask ^= b0_obs ^ b1_obs; - } - - handled[di] = true; - handled[ni] = true; } } Some(obs_mask) } - /// Predecode: single defect matches to boundary. - fn predecode_single(&self, defect: u32) -> u64 { + /// Predecode an isolated single defect, if it is provably optimal. + /// + /// The optimal correction for an isolated defect is the minimum-weight path + /// to the boundary, whose weight is at least that of the defect's lightest + /// incident edge (adjacency is sorted by weight, so that is `adj[0]`). The + /// predecoder can therefore resolve it cheaply ONLY when the lightest edge + /// goes directly to the boundary -- then that single edge IS the optimal + /// path. Otherwise the optimal path routes through the bulk (and may flip an + /// observable that a direct boundary edge would miss), so we return `None` + /// and fall through to the full decoder. + /// + /// (Returning a direct boundary edge that is not the lightest -- or `0` when + /// no direct boundary edge exists -- was the historical bug that broke + /// distance suppression: e.g. a bulk defect whose min-weight correction is a + /// logical-flipping path was silently decoded as no-flip.) + fn predecode_single(&self, defect: u32) -> Option { let boundary = self.num_detectors as u32; - // Find the lightest boundary edge from this defect. - // Adjacency is sorted by weight, so iterate and pick first boundary edge. - for &(edge_idx, neighbor) in self.adj(defect as usize) { - if neighbor == boundary { - return self.edges[edge_idx].obs_mask; - } + let &(edge_idx, neighbor) = self.adj(defect as usize).first()?; + if neighbor == boundary { + Some(self.edges[edge_idx].obs_mask) + } else { + None } - // No boundary edge found (shouldn't happen for valid surface codes). - 0 } /// Returns true if a cluster (given by its root) still needs to grow. @@ -1062,8 +1128,13 @@ impl UfDecoder { // === Trait implementations === impl pecos_decoder_core::ObservableDecoder for UfDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - Ok(self.decode_syndrome(syndrome)) + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + self.decode_syndrome(syndrome), + )) } } diff --git a/crates/pecos-uf-decoder/src/lib.rs b/crates/pecos-uf-decoder/src/lib.rs index 180bccf56..754be0c39 100644 --- a/crates/pecos-uf-decoder/src/lib.rs +++ b/crates/pecos-uf-decoder/src/lib.rs @@ -33,6 +33,7 @@ pub mod astar; pub mod bp_uf; pub mod css_decoder; pub mod decoder; +pub mod logical_subgraph_windowed; pub mod mini_bp; pub mod windowed; @@ -45,6 +46,7 @@ pub use astar::{AStarConfig, AStarDecoder}; pub use bp_uf::{BpSchedule, BpUfConfig, BpUfDecoder}; pub use css_decoder::{CssUfDecoder, QubitEdgeMapping}; pub use decoder::{UfDecoder, UfDecoderConfig}; +pub use logical_subgraph_windowed::WindowedLogicalSubgraphDecoder; pub use windowed::{ BeamSearchConfig, BeamSearchWindowedDecoder, OverlappingWindowedDecoder, SandwichWindowedDecoder, StreamingWindowedDecoder, WindowedConfig, WindowedDecoder, diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs new file mode 100644 index 000000000..c5c071879 --- /dev/null +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -0,0 +1,165 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Windowed logical-subgraph decoder with correct sliding-window core-commit. +//! +//! The logical-subgraph decoder partitions a DEM per logical observable and +//! decodes each observable's subgraph independently (the coordinate observing +//! regions of Serra-Peralta et al., arXiv:2505.13599 / the `lomatching` +//! package). For deep circuits an observing region would span the whole circuit, +//! so we additionally window each subgraph in time. +//! +//! **Nesting: subgraph -> window.** Each per-observable subgraph is a clean +//! graphlike matching graph, so we wrap it in an +//! [`OverlappingWindowedDecoder`], which performs proper sliding-window +//! decoding: every window is decoded with a buffer for matching context, but +//! only correction edges whose BOTH endpoints lie in the window core are +//! committed (Tan et al., arXiv:2209.09219). The per-observable committed +//! observable flips are XOR-combined. +//! +//! An earlier implementation windowed the full DEM first and then ran a subgraph +//! decoder per window, combining by a naive full-window observable XOR with no +//! core-commit. That double-counted error chains crossing a window boundary and +//! *anti-suppressed* (LER grew with code distance). The correct nesting here +//! reuses the tested core-commit machinery instead. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_decoder_core::errors::DecoderError; +use pecos_decoder_core::logical_subgraph::window_plan::LogicalSubgraphWindowPlan; +use pecos_decoder_core::logical_subgraph::{ + MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, +}; +use pecos_decoder_core::obs_mask::ObsMask; + +use crate::decoder::{UfDecoder, UfDecoderConfig}; +use crate::windowed::{OverlappingWindowedDecoder, WindowedConfig}; + +/// One per-observable subgraph, windowed with sliding-window core-commit. +struct SubgraphWindowed { + /// Which full-DEM observable this subgraph decodes (the global bit index). + observable_idx: usize, + /// Subgraph-local detector index -> full-DEM detector index. + detector_map: Vec, + /// Number of subgraph-local detectors. + num_local: usize, + /// The time-windowed decoder over this subgraph (returns local bit 0). + decoder: OverlappingWindowedDecoder, +} + +/// Windowed logical-subgraph decoder. +/// +/// Partitions the DEM per observable, then windows each subgraph with an +/// [`OverlappingWindowedDecoder`] (sliding-window core-commit). Per-observable +/// committed observable flips are XOR-combined into the final mask. +pub struct WindowedLogicalSubgraphDecoder { + subgraphs: Vec, + /// Reusable subgraph-local syndrome buffer (sized to the largest subgraph). + local_syn: Vec, +} + +impl WindowedLogicalSubgraphDecoder { + /// Build from a full DEM string and stabilizer coordinates. + /// + /// `max_time_radius` controls the per-observable observing region (see + /// [`partition_dem_by_logical_windowed`]); pass `None` for the full region + /// (the windowing then bounds the time extent instead). + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed or a subgraph decoder + /// fails to build. + pub fn from_dem( + dem: &str, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, + window_config: WindowedConfig, + ) -> Result { + let parts = partition_dem_by_logical_windowed(dem, stab_coords, max_time_radius)?; + + // Shared coord-preserving plan: subgraph graphs carry no detector + // coordinates, so the plan re-injects the full-DEM coords (mapped to + // subgraph-local indices) into each sub-DEM, giving the time-based + // windowing real detector times. Empty-region observables are dropped. + let full_coords = DemMatchingGraph::from_dem_str(dem)?.detector_coords; + let plan = LogicalSubgraphWindowPlan::new(&parts, &full_coords); + + let mut subgraphs = Vec::with_capacity(plan.num_observables()); + let mut max_local = 0usize; + for entry in plan.entries() { + let decoder = + OverlappingWindowedDecoder::from_dem(&entry.sub_dem, window_config, |wdem| { + UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) + })?; + let num_local = entry.detector_map.len(); + max_local = max_local.max(num_local); + subgraphs.push(SubgraphWindowed { + observable_idx: entry.observable_idx, + detector_map: entry.detector_map.clone(), + num_local, + decoder, + }); + } + + Ok(Self { + subgraphs, + local_syn: vec![0u8; max_local], + }) + } + + /// Number of per-observable subgraphs that actually decode (non-empty). + #[must_use] + pub fn num_subgraphs(&self) -> usize { + self.subgraphs.len() + } + + /// Total number of windows across all subgraphs. + #[must_use] + pub fn num_windows(&self) -> usize { + self.subgraphs.iter().map(|s| s.decoder.num_windows()).sum() + } +} + +impl ObservableDecoder for WindowedLogicalSubgraphDecoder { + /// Narrowing wrapper over [`Self::decode_obs`]; errors (rather than + /// truncating) above 64 observables. + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + ) + }) + } + + /// Decode every windowed per-observable subgraph and pack the flips into a + /// wide [`ObsMask`] at each subgraph's GLOBAL observable index (no >64 cap). + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = ObsMask::new(); + for sg in &mut self.subgraphs { + let n = sg.num_local; + for (local, &global) in sg.detector_map.iter().enumerate() { + self.local_syn[local] = if global < syndrome.len() { + syndrome[global] + } else { + 0 + }; + } + // The subgraph decodes a single observable as its local bit 0; map + // that back to this observable's global bit. + let sub_obs = sg.decoder.decode_to_observables(&self.local_syn[..n])?; + if sub_obs & 1 != 0 { + obs_mask.set(sg.observable_idx); + } + } + Ok(obs_mask) + } +} diff --git a/crates/pecos-uf-decoder/src/windowed.rs b/crates/pecos-uf-decoder/src/windowed.rs index 8864adaf8..fad046960 100644 --- a/crates/pecos-uf-decoder/src/windowed.rs +++ b/crates/pecos-uf-decoder/src/windowed.rs @@ -120,7 +120,10 @@ impl WindowedDecoder { } impl ObservableDecoder for WindowedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let mut obs_mask = 0u64; for window in &mut self.windows { let mut window_syn = vec![0u8; window.num_local]; @@ -132,7 +135,7 @@ impl ObservableDecoder for WindowedDecoder { } obs_mask ^= window.decoder.decode_to_observables(&window_syn)?; } - Ok(obs_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs_mask)) } } @@ -227,7 +230,10 @@ impl OverlappingWindowedDecoder { } impl ObservableDecoder for OverlappingWindowedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let mut obs_mask = 0u64; for window in &mut self.windows { @@ -258,7 +264,7 @@ impl ObservableDecoder for OverlappingWindowedDecoder } } - Ok(obs_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs_mask)) } } @@ -450,7 +456,10 @@ impl SandwichWindowedDecoder { } impl ObservableDecoder for SandwichWindowedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let mut obs_mask = 0u64; let mut correction_effect = vec![0u8; self.num_detectors]; let commit_weight_max = self.commit_weight_max; @@ -504,7 +513,7 @@ impl ObservableDecoder for SandwichWindowedDecoder { } obs_mask ^= self.residual_decoder.decode_to_observables(&residual_syn)?; - Ok(obs_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs_mask)) } } @@ -1033,7 +1042,10 @@ impl BeamSearchWindowedDecoder { } impl ObservableDecoder for BeamSearchWindowedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let k = self.beam_width; let commit_weight_max = self.commit_weight_max; @@ -1123,7 +1135,7 @@ impl ObservableDecoder for BeamSearchWindowedDecoder // Each hypothesis may have a different Phase-1 obs_mask; we also run // Phase-2 on each to get the complete observable prediction. if beam.is_empty() { - return Ok(0); + return Ok(pecos_decoder_core::obs_mask::ObsMask::new()); } // Collect final observable predictions from each hypothesis. @@ -1155,7 +1167,7 @@ impl ObservableDecoder for BeamSearchWindowedDecoder result |= mask; } } - Ok(result) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(result)) } } diff --git a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs index d7bec84d7..1f09aec70 100644 --- a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs +++ b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs @@ -30,13 +30,11 @@ const D3_DEM: &str = fn test_ensemble_of_identical_decoders_matches_single() { let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut single = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut single = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let members: Vec> = (0..3) .map(|_| { - Box::new(UfDecoder::from_matching_graph( - &graph, - UfDecoderConfig::default(), - )) as Box + Box::new(UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap()) + as Box }) .collect(); let mut ensemble = EnsembleDecoder::new(members); @@ -64,8 +62,8 @@ fn test_ensemble_of_identical_decoders_matches_single() { fn test_deterministic_results() { let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut dec1 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); - let mut dec2 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec1 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec2 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let mut rng = fastrand::Rng::with_seed(999); for _ in 0..200 { @@ -89,7 +87,7 @@ fn test_matching_agrees_with_observable() { use pecos_decoder_core::correlated_decoder::MatchingDecoder; let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let mut rng = fastrand::Rng::with_seed(777); for _ in 0..200 { diff --git a/crates/pecos-uf-decoder/tests/integration_tests.rs b/crates/pecos-uf-decoder/tests/integration_tests.rs index 985a179b9..ba4b9f845 100644 --- a/crates/pecos-uf-decoder/tests/integration_tests.rs +++ b/crates/pecos-uf-decoder/tests/integration_tests.rs @@ -37,7 +37,7 @@ fn test_real_dem_construction() { graph.edges.len() ); - let dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); assert_eq!(dec.num_detectors(), graph.num_detectors); assert_eq!(dec.num_edges(), graph.edges.len()); } @@ -46,7 +46,7 @@ fn test_real_dem_construction() { #[test] fn test_real_dem_no_errors() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let syndrome = vec![0u8; graph.num_detectors]; assert_eq!(dec.decode_syndrome(&syndrome), 0); } @@ -56,7 +56,7 @@ fn test_real_dem_no_errors() { #[test] fn test_real_dem_single_defects() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); for d in 0..graph.num_detectors { let mut syndrome = vec![0u8; graph.num_detectors]; @@ -75,7 +75,7 @@ fn test_real_dem_single_defects() { #[test] fn test_real_dem_adjacent_pairs() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); for edge in &graph.edges { let mut syndrome = vec![0u8; graph.num_detectors]; @@ -93,7 +93,7 @@ fn test_real_dem_adjacent_pairs() { #[test] fn test_real_dem_random_syndromes() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let mut rng = fastrand::Rng::with_seed(42); for _ in 0..1000 { @@ -121,7 +121,7 @@ fn test_real_dem_random_syndromes() { #[test] fn test_observable_decoder_trait_real_dem() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let syndrome = vec![0u8; graph.num_detectors]; let result = dec.decode_to_observables(&syndrome); assert!(result.is_ok()); @@ -133,7 +133,7 @@ fn test_observable_decoder_trait_real_dem() { fn test_matching_decoder_trait_real_dem() { use pecos_decoder_core::correlated_decoder::MatchingDecoder; let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); // Two adjacent defects. let edge = &graph.edges[0]; @@ -154,7 +154,7 @@ fn test_matching_decoder_trait_real_dem() { #[test] fn test_buffer_reuse_correctness() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let zero_syndrome = vec![0u8; graph.num_detectors]; let mut defect_syndrome = vec![0u8; graph.num_detectors]; @@ -171,3 +171,38 @@ fn test_buffer_reuse_correctness() { let _ = dec.decode_syndrome(&defect_syndrome); } } + +/// A DEM line with an error prior p > 0.5 produces a negative edge weight +/// (`ln((1-p)/p) < 0`), which the predecoder's optimality proofs do not admit. +/// The DEM-text constructors must reject it as an error, not mis-decode. +#[test] +fn test_from_dem_rejects_negative_weight_priors() { + let dem = "error(0.6) D0 L0\nerror(0.01) D0 D1\n"; + assert!(UfDecoder::from_dem(dem, UfDecoderConfig::balanced()).is_err()); + assert!( + pecos_uf_decoder::BpUfDecoder::from_dem(dem, pecos_uf_decoder::BpUfConfig::balanced()) + .is_err() + ); + assert!( + pecos_uf_decoder::CssUfDecoder::from_dems(dem, dem, UfDecoderConfig::balanced()).is_err() + ); +} + +/// The graph-level constructor asserts the same premise loudly for callers +/// that build graphs directly (contract violation, not user input). +#[test] +#[should_panic(expected = "non-negative edge weights")] +fn test_from_matching_graph_asserts_non_negative_weights() { + let dem = "error(0.6) D0 L0\n"; + let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); + let _ = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()).unwrap(); +} + +/// Weight-zero edges (p = 0.5) are benign: the shortcut proofs hold as ties. +#[test] +fn test_from_dem_accepts_weight_zero_edges() { + let dem = "error(0.5) D0 L0\nerror(0.01) D0 D1\n"; + let mut dec = UfDecoder::from_dem(dem, UfDecoderConfig::balanced()).unwrap(); + let syndrome = vec![0u8; dec.num_detectors()]; + assert_eq!(dec.decode_syndrome(&syndrome), 0); +} diff --git a/crates/pecos/Cargo.toml b/crates/pecos/Cargo.toml index 85c779ced..a6836f2f5 100644 --- a/crates/pecos/Cargo.toml +++ b/crates/pecos/Cargo.toml @@ -28,6 +28,7 @@ pecos-qis = { workspace = true, optional = true } pecos-llvm = { workspace = true, optional = true } pecos-hugr-qis = { workspace = true, optional = true } pecos-hugr = { workspace = true, optional = true } +pecos-neo = { workspace = true, optional = true } pecos-phir = { workspace = true, optional = true, features = ["hugr"] } pecos-random = { workspace = true, optional = true } pecos-num = { workspace = true, optional = true } @@ -70,6 +71,9 @@ sim = [ # Runtime: enables full simulation library with QASM and PHIR support runtime = ["sim", "qasm", "phir"] +# Experimental: route sim() to the pecos-neo stack via .stack(SimStack::Neo) +neo = ["runtime", "dep:pecos-neo", "pecos-neo/qasm", "pecos-neo/hugr"] + # Program formats (require sim) qasm = ["sim", "dep:pecos-qasm"] phir = ["sim", "dep:pecos-phir-json"] @@ -105,6 +109,7 @@ qec = ["quantum", "dep:pecos-qec"] full = ["runtime", "all-simulators", "all-decoders", "hugr", "wasm", "qec"] [dev-dependencies] +criterion.workspace = true tempfile.workspace = true assert_cmd.workspace = true # Required for doctests @@ -117,11 +122,25 @@ pecos-phir-json.workspace = true pecos-random.workspace = true log.workspace = true serde_json.workspace = true +# Surface-code LER equivalence test (neo validation gate) +pecos-qec.workspace = true +pecos-quantum.workspace = true +pecos-decoder-core.workspace = true +pecos-fusion-blossom.workspace = true +pecos-num.workspace = true [[test]] name = "unified_sim_api_test" required-features = ["runtime", "qis"] +[[test]] +name = "neo_surface_ler_test" +required-features = ["neo"] + +[[test]] +name = "neo_equivalence_matrix_test" +required-features = ["neo"] + [[example]] name = "sim_api_final" required-features = ["runtime", "qis"] @@ -144,3 +163,8 @@ required-features = ["runtime"] [lints] workspace = true + +[[bench]] +name = "stack_comparison" +harness = false +required-features = ["neo"] diff --git a/crates/pecos/README.md b/crates/pecos/README.md index 47fc78eea..4a8dc3d2f 100644 --- a/crates/pecos/README.md +++ b/crates/pecos/README.md @@ -8,13 +8,13 @@ Provides a unified API for PECOS users. Most users should depend on this crate r ## Key Features -- **Unified simulation API**: `sim(program).seed(42).run(100)` +- **Unified simulation API**: `sim(program).seed(42).shots(100).run()` - **Re-exports**: Core types, engines, programs, quantum backends - **Feature-gated**: Enable only what you need (qasm, qis, hugr, etc.) ## Feature Flags - `runtime` (default): Full simulation with QASM/PHIR support -- `qis`: QIS/LLVM IR execution (requires LLVM 14) +- `qis`: QIS/LLVM IR execution (requires LLVM 21.1) - `hugr`: HUGR program support - `quest`, `qulacs`: Additional quantum backends diff --git a/crates/pecos/benches/stack_comparison.rs b/crates/pecos/benches/stack_comparison.rs new file mode 100644 index 000000000..79343f61e --- /dev/null +++ b/crates/pecos/benches/stack_comparison.rs @@ -0,0 +1,113 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Engines-vs-neo stack baselines for the transition validation gate. +//! +//! Measures end-to-end `sim(qasm).run(shots)` (parse + build + execute) on +//! both stacks over a standard circuit set. Run with: +//! `cargo bench -p pecos --features neo --bench stack_comparison` + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use pecos::{SimStack, sim}; +use pecos_programs::Qasm; +use std::fmt::Write; + +const SHOTS: usize = 1000; + +fn bell() -> String { + r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + h q[0]; + cx q[0], q[1]; + measure q -> c; + "# + .to_string() +} + +fn ghz(n: usize) -> String { + let mut s = + format!("OPENQASM 2.0;\ninclude \"qelib1.inc\";\nqreg q[{n}];\ncreg c[{n}];\nh q[0];\n"); + for i in 1..n { + let _ = writeln!(s, "cx q[{}], q[{i}];", i - 1); + } + s.push_str("measure q -> c;\n"); + s +} + +/// Chain of measure-and-correct rounds: exercises the feedback path. +fn feedback_chain(rounds: usize) -> String { + let mut s = String::from("OPENQASM 2.0;\ninclude \"qelib1.inc\";\nqreg q[2];\n"); + for r in 0..rounds { + let _ = writeln!(s, "creg c{r}[1];"); + } + for r in 0..rounds { + let _ = writeln!( + s, + "h q[0];\nmeasure q[0] -> c{r}[0];\nif (c{r} == 1) x q[0];" + ); + } + s +} + +/// Layered Clifford circuit: H + S on every qubit, CX ladder, repeated. +fn clifford_layers(n: usize, depth: usize) -> String { + let mut s = format!("OPENQASM 2.0;\ninclude \"qelib1.inc\";\nqreg q[{n}];\ncreg c[{n}];\n"); + for _ in 0..depth { + for i in 0..n { + let _ = writeln!(s, "h q[{i}];\ns q[{i}];"); + } + for i in (0..n - 1).step_by(2) { + let _ = writeln!(s, "cx q[{}], q[{}];", i, i + 1); + } + } + s.push_str("measure q -> c;\n"); + s +} + +fn run_stack(qasm: &str, stack: SimStack, noisy: bool) { + let mut builder = sim(Qasm::from_string(qasm)).stack(stack).seed(42); + if noisy { + builder = builder.noise(pecos_engines::DepolarizingNoise { p: 0.001 }); + } + let results = builder.shots(SHOTS).run().expect("run"); + assert_eq!(results.shots.len(), SHOTS); +} + +fn bench_stacks(c: &mut Criterion) { + let cases: Vec<(&str, String, bool)> = vec![ + ("bell", bell(), false), + ("bell_noisy", bell(), true), + ("ghz10", ghz(10), false), + ("feedback16", feedback_chain(16), false), + ("clifford_12q_x8", clifford_layers(12, 8), false), + ("clifford_12q_x8_noisy", clifford_layers(12, 8), true), + ]; + + let mut group = c.benchmark_group(format!("sim_run_{SHOTS}_shots")); + group.sample_size(10); + for (name, qasm, noisy) in &cases { + for (stack_name, stack) in [("engines", SimStack::Engines), ("neo", SimStack::Neo)] { + group.bench_with_input( + BenchmarkId::new(*name, stack_name), + &(qasm.as_str(), stack, *noisy), + |b, &(qasm, stack, noisy)| b.iter(|| run_stack(qasm, stack, noisy)), + ); + } + } + group.finish(); +} + +criterion_group!(benches, bench_stacks); +criterion_main!(benches); diff --git a/crates/pecos/examples/sim_api_examples.rs b/crates/pecos/examples/sim_api_examples.rs index f2045c86f..a0c43f146 100644 --- a/crates/pecos/examples/sim_api_examples.rs +++ b/crates/pecos/examples/sim_api_examples.rs @@ -15,7 +15,8 @@ fn main() -> Result<(), PecosError> { .quantum(state_vector()) .noise(DepolarizingNoise { p: 0.01 }) .seed(42) - .run(50)?; + .shots(50) + .run()?; println!(" Results: {} shots", results.len()); // Example 2: Different program types @@ -23,7 +24,11 @@ fn main() -> Result<(), PecosError> { // QASM program let qasm_prog = Qasm::from_string("OPENQASM 2.0; qreg q[2]; h q[0]; cx q[0],q[1];"); - let results = sim(qasm_prog).quantum(sparse_stab()).seed(42).run(100)?; + let results = sim(qasm_prog) + .quantum(sparse_stab()) + .seed(42) + .shots(100) + .run()?; println!(" QASM: {} shots", results.len()); // LLVM program @@ -41,7 +46,8 @@ fn main() -> Result<(), PecosError> { ); let results = sim(llvm_prog) .qubits(1) // LLVM programs need explicit qubit count - .run(50)?; + .shots(50) + .run()?; println!(" LLVM: {} shots", results.len()); // Example 3: Using sim_builder() for empty builder @@ -76,7 +82,8 @@ fn main() -> Result<(), PecosError> { // QASM program but use LLVM engine let results = sim(qasm_prog) .classical(qis_engine().program(llvm_prog)) - .run(20)?; + .shots(20) + .run()?; println!(" Results: {} shots", results.len()); // Example 5: Build once, run multiple times @@ -112,7 +119,8 @@ fn main() -> Result<(), PecosError> { Qasm::from_string("OPENQASM 2.0; qreg q[3]; h q[0]; cx q[0],q[1]; cx q[1],q[2];"); let results = sim(qasm_prog) .auto_workers() // Use all available CPU cores - .run(1000)?; + .shots(1000) + .run()?; println!(" Results: {} shots with auto workers", results.len()); // Example 7: Using engine builder with sim_from() diff --git a/crates/pecos/examples/sim_api_final.rs b/crates/pecos/examples/sim_api_final.rs index 5924dd905..5acb6f1ec 100644 --- a/crates/pecos/examples/sim_api_final.rs +++ b/crates/pecos/examples/sim_api_final.rs @@ -30,7 +30,8 @@ fn main() -> Result<(), PecosError> { .noise(DepolarizingNoise { p: 0.01 }) .seed(42) .workers(4) - .run(1000)?; + .shots(1000) + .run()?; println!( " Bell state simulation: {} shots completed", @@ -101,7 +102,8 @@ fn main() -> Result<(), PecosError> { let results = sim(qasm_prog) .classical(qis_engine().program(llvm_prog)) .qubits(1) - .run(10)?; + .shots(10) + .run()?; println!(" Override engine: {} shots", results.len()); diff --git a/crates/pecos/examples/unified_sim_auto_selection.rs b/crates/pecos/examples/unified_sim_auto_selection.rs index a193caac5..72c362b50 100644 --- a/crates/pecos/examples/unified_sim_auto_selection.rs +++ b/crates/pecos/examples/unified_sim_auto_selection.rs @@ -22,7 +22,11 @@ fn main() -> Result<(), Box> { "#, ); - let results = sim(qasm_prog).seed(42).quantum(state_vector()).run(100)?; + let results = sim(qasm_prog) + .seed(42) + .quantum(state_vector()) + .shots(100) + .run()?; println!(" Ran {} shots for QASM program", results.len()); @@ -63,7 +67,8 @@ fn main() -> Result<(), Box> { .workers(2) .verbose(false) .quantum(sparse_stab()) - .run(200)?; + .shots(200) + .run()?; println!(" Ran {} shots with custom configuration", results4.len()); diff --git a/crates/pecos/examples/unified_sim_demo.rs b/crates/pecos/examples/unified_sim_demo.rs index 58d4d369d..047082347 100644 --- a/crates/pecos/examples/unified_sim_demo.rs +++ b/crates/pecos/examples/unified_sim_demo.rs @@ -52,7 +52,8 @@ fn main() -> Result<(), Box> { .seed(123) .workers(4) .quantum(sparse_stab()) - .run(500)?; + .shots(500) + .run()?; println!(" Ran {} shots", results2.len()); @@ -74,7 +75,8 @@ fn main() -> Result<(), Box> { let results3 = sim(qasm3.clone()) .classical(qasm_engine().program(qasm3)) .verbose(true) - .run(100)?; + .shots(100) + .run()?; println!(" Ran {} shots", results3.len()); diff --git a/crates/pecos/examples/unified_sim_reusable.rs b/crates/pecos/examples/unified_sim_reusable.rs index 83e2698e7..fb3216f4d 100644 --- a/crates/pecos/examples/unified_sim_reusable.rs +++ b/crates/pecos/examples/unified_sim_reusable.rs @@ -88,7 +88,7 @@ fn main() -> Result<(), Box> { // Direct run (builds each time) let start = Instant::now(); for _ in 0..5 { - let _ = sim(qasm3.clone()).run(100)?; + let _ = sim(qasm3.clone()).shots(100).run()?; } let direct_time = start.elapsed(); println!(" Direct run 5 times: {direct_time:?}"); diff --git a/crates/pecos/src/lib.rs b/crates/pecos/src/lib.rs index 95244ce20..52a90f5cf 100644 --- a/crates/pecos/src/lib.rs +++ b/crates/pecos/src/lib.rs @@ -5,7 +5,7 @@ //! - **`core`**: Core types and error handling //! - **`sim`**: Quantum simulation (includes core + num) //! - **`runtime`**: Full simulation with QASM + PHIR support -//! - **`qis`**: QIS/LLVM IR execution (requires LLVM 14) +//! - **`qis`**: QIS/LLVM IR execution (requires LLVM 21.1) //! - **`hugr`**: HUGR program support //! - **`quest`/`qulacs`/`cppsparsestab`**: Simulator backends //! - **`num`**: Numerical computing (scipy-like) @@ -194,11 +194,13 @@ pub use engine_type::{DynamicEngineBuilder, EngineType, sim_dynamic}; #[cfg(feature = "cppsparsestab")] pub use pecos_cppsparsestab::CppSparseStab; #[cfg(feature = "sim")] +pub use pecos_engines::sampling::monte_carlo; +#[cfg(feature = "sim")] pub use pecos_engines::{ BiasedDepolarizingNoise, DepolarizingNoise, GeneralNoiseModelBuilder, PassThroughNoiseModel, }; #[cfg(feature = "sim")] -pub use pecos_engines::{SimInput, sim_builder}; +pub use pecos_engines::{MonteCarloBuilder, SimInput, sim_builder}; #[cfg(feature = "sim")] pub use pecos_engines::{ coin_toss, density_matrix, sparse_stab, stab_vec, stabilizer, state_vector, @@ -225,4 +227,4 @@ pub use pecos_qis::{QisEngineBuilder, qis_engine, setup_qis_engine_with_runtime} #[cfg(feature = "wasm")] pub use pecos_wasm::{ForeignObject, WasmForeignObject}; #[cfg(feature = "runtime")] -pub use unified_sim::{ProgrammedSimBuilder, SimBuilderExt, sim}; +pub use unified_sim::{ProgrammedSimBuilder, SimBuilderExt, SimStack, sim}; diff --git a/crates/pecos/src/prelude.rs b/crates/pecos/src/prelude.rs index d2e35d6b7..072e34f38 100644 --- a/crates/pecos/src/prelude.rs +++ b/crates/pecos/src/prelude.rs @@ -32,7 +32,8 @@ //! let results = sim(program) //! .quantum(sparse_stab()) //! .seed(42) -//! .run(1000)?; +//! .shots(1000) +//! .run()?; //! # Ok::<(), pecos_core::errors::PecosError>(()) //! ``` //! diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 06194e162..89caf04dc 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -4,7 +4,10 @@ //! from pecos-engines, adding automatic engine selection based on program type. use pecos_core::errors::PecosError; -use pecos_engines::{ClassicalControlEngineBuilder, MonteCarloEngine, SimBuilder, sim_builder}; +use pecos_engines::sampling::monte_carlo; +use pecos_engines::{ + ClassicalControlEngineBuilder, MonteCarloBuilder, MonteCarloEngine, SimBuilder, sim_builder, +}; use pecos_programs::Program; use pecos_qasm::qasm_engine; #[cfg(feature = "qis")] @@ -25,6 +28,34 @@ fn build_qis_engine( .map_err(|e| PecosError::Generic(format!("Failed to load program: {e}"))) } +/// Which simulation stack executes the program. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SimStack { + /// The engine/`EngineSystem` stack in `pecos-engines` (current default). + #[default] + Engines, + /// The data-oriented `pecos-neo` stack (experimental). + /// + /// Requires building pecos with the `neo` cargo feature. Routes QASM and + /// HUGR programs with the default quantum backend. HUGR runs through the + /// PHIR engine, so its results use the same named-register contract as the + /// engines/QASM path with no Selene/LLVM dependency -- but only for the + /// PHIR converter's STRAIGHT-LINE subset; HUGR with classical control flow + /// (loops, conditionals) is rejected (use `SimStack::Engines` for those). + /// (Note: the engines stack runs HUGR through QIS/Selene, a different and + /// broader HUGR engine -- a consideration for the eventual default flip.) + /// The translated noise surface is the depolarizing family + /// (`PassThroughNoise`, `DepolarizingNoise`, `BiasedDepolarizingNoise`, + /// and their builders) and the `GeneralNoiseModel` simple-probability + /// subset, including angle-dependent two-qubit scaling and the + /// gate-removing spontaneous-emission ratios (with the default uniform + /// emission distribution). Other noise configurations (leakage, idle, + /// crosstalk, custom emission distributions, ...), explicit + /// `.classical()`, and explicit `.quantum()` are not yet translated and + /// are rejected with an error at `run()`. + Neo, +} + /// Extension trait for `SimBuilder` to add program-based methods pub trait SimBuilderExt { /// Set the program and automatically select an appropriate engine @@ -46,15 +77,41 @@ impl SimBuilderExt for SimBuilder { base_builder: self, program: program.into(), override_classical: false, + stack: SimStack::default(), + routed: RoutedConfig::default(), } } } +/// Config recorded at the facade for routing to the neo stack. +/// +/// The engines `SimBuilder` keeps its own copy via the delegating setters; +/// this records what the neo translation needs (values it can map, flags +/// for config it cannot yet map and must reject). +#[derive(Default)] +struct RoutedConfig { + seed: Option, + workers: Option, + auto_workers: bool, + qubits: Option, + /// Monte Carlo shot count, set via `.shots(n)` and consumed by the argless + /// `.run()`. `None` until configured -- `.run()` fails fast rather than + /// defaulting silently. + shots: Option, + /// The noise config as passed, for translation to the neo stack. + /// Type-erased because `.noise()` is generic; the neo route downcasts + /// against the known engines noise types. + noise: Option>, + quantum_set: bool, +} + /// A simulation builder that has a program set and can auto-select engines pub struct ProgrammedSimBuilder { base_builder: SimBuilder, program: Program, override_classical: bool, + stack: SimStack, + routed: RoutedConfig, } impl ProgrammedSimBuilder { @@ -109,6 +166,18 @@ impl ProgrammedSimBuilder { } } + /// Select which simulation stack executes the program. + /// + /// Defaults to [`SimStack::Engines`]. [`SimStack::Neo`] is experimental + /// and requires the `neo` cargo feature; see [`SimStack`] for the + /// configuration it can route so far. The result type and contract are + /// identical on both stacks. + #[must_use] + pub fn stack(mut self, stack: SimStack) -> Self { + self.stack = stack; + self + } + /// Build the simulation with automatic engine selection /// /// # Errors @@ -116,21 +185,343 @@ impl ProgrammedSimBuilder { /// Returns an error if: /// - The program type is not yet supported (WASM, WAT, PHIR JSON, `SeleneInterface`) /// - Engine building fails + /// - The neo stack is selected (it has no `MonteCarloEngine`; use + /// [`run()`](Self::run) directly) pub fn build(self) -> Result { + if self.stack == SimStack::Neo { + return Err(PecosError::Input( + "The neo stack does not expose a MonteCarloEngine; call .shots(n).run() directly." + .to_string(), + )); + } self.configure_engine()?.build() } - /// Build and run the simulation with automatic engine selection + /// Set the number of Monte Carlo shots to run. + /// + /// Shorthand for [`sampling(monte_carlo(shots))`](Self::sampling). Shots are + /// a builder concern, not a `run()` argument: configure the count here, then + /// call the argless [`run()`](Self::run). Both stacks (and the neo + /// `sim_neo()` builder) share this `.shots(n).run()` shape. + #[must_use] + pub fn shots(self, shots: usize) -> Self { + self.sampling(monte_carlo(shots)) + } + + /// Set the Monte Carlo sampling strategy (shot count plus optional worker + /// parallelism), e.g. `.sampling(monte_carlo(1000).workers(8))`. + /// + /// [`monte_carlo()`](pecos_engines::sampling::monte_carlo) is the shared + /// cross-stack run-spec, so the SAME spelling works on both the engines and + /// neo stacks. The shot count is required; worker settings are applied only + /// when explicitly configured on the spec, so this never silently overrides + /// a separate [`workers()`](Self::workers) call unless the spec sets workers + /// too. (Richer rare-event strategies -- importance sampling, subset + /// simulation -- are neo-only and configured via `sim_neo()` directly.) + #[must_use] + pub fn sampling(mut self, sampling: impl Into) -> Self { + let mc = sampling.into(); + self.routed.shots = Some(mc.shots()); + // Worker settings are mutually exclusive and last-writer-wins (see + // `workers`/`auto_workers`): apply only what the spec sets, clearing the + // other, so the neo route can't end up with both flags live. + if mc.auto_workers_requested() { + self.routed.auto_workers = true; + self.routed.workers = None; + self.base_builder = self.base_builder.auto_workers(); + } else if let Some(workers) = mc.worker_count() { + self.routed.workers = Some(workers); + self.routed.auto_workers = false; + self.base_builder = self.base_builder.workers(workers); + } + self + } + + /// Build and run the simulation with automatic engine selection. + /// + /// The shot count must be configured first via [`shots()`](Self::shots); + /// `run()` takes no argument and fails fast if no count was set, rather than + /// defaulting silently. /// /// # Errors /// /// Returns an error if: + /// - No shot count was configured via [`shots()`](Self::shots) /// - The program type is not yet supported (WASM, WAT, PHIR JSON, `SeleneInterface`) /// - Engine building or running fails - pub fn run(self, shots: usize) -> Result { - self.configure_engine()?.run(shots) + /// - The neo stack is selected with configuration it cannot route yet + pub fn run(self) -> Result { + let shots = self.routed.shots.ok_or_else(|| { + PecosError::Input( + "No shot count configured; set one with .shots(n) before .run(). \ + Example: sim(program).shots(1000).run()." + .to_string(), + ) + })?; + match self.stack { + SimStack::Engines => self.configure_engine()?.run(shots), + SimStack::Neo => self.run_neo(shots), + } + } + + /// Run the program on the pecos-neo stack. + #[cfg(feature = "neo")] + fn run_neo(self, shots: usize) -> Result { + use pecos_neo::tool::{monte_carlo, sim_neo, sim_neo_builder}; + + if self.override_classical { + return Err(PecosError::Input( + "Explicit .classical() engine builders are not yet routed to the neo stack; \ + remove .classical() or use .stack(SimStack::Engines)." + .to_string(), + )); + } + let neo_noise = match &self.routed.noise { + None => None, + Some(noise) => map_noise_to_neo(noise.as_ref())?, + }; + if self.routed.quantum_set { + return Err(PecosError::Input( + "Explicit quantum backends are not yet routed to the neo stack (it uses the \ + default sparse stabilizer); remove .quantum() or use .stack(SimStack::Engines)." + .to_string(), + )); + } + + let mut sampler = monte_carlo(shots); + if let Some(workers) = self.routed.workers { + sampler = sampler.workers(workers); + } + if self.routed.auto_workers { + sampler = sampler.auto_workers(); + } + + // QASM auto-selects the QASM engine. HUGR is routed through the PHIR + // engine (HUGR -> PHIR), which emits the program's NAMED classical + // register (e.g. "c") -- matching the engines/QASM result contract -- + // and needs no Selene/LLVM. (neo's own `hugr_engine` would instead emit + // per-qubit `q0`/`q1` and a `measurements` array, which is not + // drop-in compatible; the named-register PHIR path is, so it is the one + // routed here.) The PHIR converter is STRAIGHT-LINE only: HUGR with + // classical control flow is rejected by `from_hugr_bytes` below (and + // any residual empty-result shape is caught by the contract guard after + // `run`). + let configured = match self.program { + Program::Qasm(qasm) => sim_neo(qasm).auto(), + Program::Hugr(hugr) => { + let phir_engine = pecos_phir::phir_engine() + .from_hugr_bytes(&hugr.hugr) + .map_err(|e| { + PecosError::Generic(format!("Failed to load HUGR program: {e}")) + })?; + sim_neo_builder().with_engine(phir_engine).auto() + } + _ => { + return Err(PecosError::Input( + "Only QASM and HUGR programs are routed to the neo stack so far; \ + use .stack(SimStack::Engines) for other program types." + .to_string(), + )); + } + }; + + let mut builder = configured.sampling(sampler); + if let Some(seed) = self.routed.seed { + builder = builder.seed(seed); + } + if let Some(qubits) = self.routed.qubits { + builder = builder.qubits(qubits); + } + if let Some(noise) = neo_noise { + builder = builder.noise(noise); + } + + let results = builder.run(); + let shot_vec = results.shots.ok_or_else(|| { + PecosError::Generic( + "The neo stack produced no register results for a classical-engine program; \ + this is a bug in the neo routing." + .to_string(), + ) + })?; + + // Result-contract guard. A HUGR shape the straight-line PHIR converter + // cannot represent can yield shots with NO register data instead of a + // clean load error (e.g. an op silently skipped during conversion). + // Surface that as an error rather than returning empty results that + // look like a successful run. (QASM always carries its cregs, so this + // never trips there.) + if !shot_vec.shots.is_empty() && shot_vec.shots.iter().all(|shot| shot.data.is_empty()) { + return Err(PecosError::Input( + "The neo stack produced empty results (no register data) for this program. \ + If it is a HUGR program, it likely uses features the straight-line PHIR \ + route does not support; use .stack(SimStack::Engines)." + .to_string(), + )); + } + Ok(shot_vec) + } + + /// Stub when pecos is built without the `neo` feature. + #[cfg(not(feature = "neo"))] + fn run_neo(self, _shots: usize) -> Result { + Err(PecosError::Input( + "pecos was built without the 'neo' cargo feature; rebuild with features = [\"neo\"] \ + to route sim() to the neo stack." + .to_string(), + )) } +} +/// Translate an engines noise config into the neo stack's noise model. +/// +/// Gate and prep conventions are identical on both stacks (uniform X/Y/Z +/// at p1, uniform 15 two-qubit Paulis at p2, X after prep for `p_prep`) +/// and probabilities map one-to-one. Measurement noise differs BY MODEL +/// on the engines side and the mapping preserves each model's physics: +/// +/// - The depolarizing family injects a physical X into the state before +/// each measurement (the error persists and propagates — a qubit +/// measured twice without a reset flips at `2p(1-p)` the second time), +/// mapped to neo's `MeasurementStateFlipChannel` via +/// `with_p_meas_state_flip`. +/// - `GeneralNoiseModel` flips only the classical record (the +/// post-measurement state is untouched), mapped to neo's +/// record-flipping `MeasurementChannel` via `with_p_meas`. +/// +/// `GeneralNoiseModel` beyond the simple probability subset is NOT +/// mapped: its full configuration (leakage, idle, crosstalk, emission +/// models) is not readable from the built model; configure `sim_neo()` +/// directly with neo's `GeneralNoiseModelBuilder` for those. +/// +/// Returns `Ok(None)` for pass-through (no noise). +#[cfg(feature = "neo")] +fn map_noise_to_neo( + noise: &(dyn std::any::Any + Send), +) -> Result, PecosError> { + use pecos_engines::noise::{ + BiasedDepolarizingNoiseModelBuilder, DepolarizingNoiseModelBuilder, + PassThroughNoiseModelBuilder, + }; + use pecos_engines::{BiasedDepolarizingNoise, DepolarizingNoise, PassThroughNoise}; + use pecos_neo::noise::{AngleScaling, GeneralNoiseModelBuilder}; + + let uniform = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { + GeneralNoiseModelBuilder::new() + .with_p_prep(p_prep) + .with_p_meas_state_flip(p_meas) + .with_p1(p1) + .with_p2(p2) + }; + + // The biased-depolarizing family applies its measurement bias to the + // RECORDED outcome AFTER readout (`apply_bias_to_measurement`), never to + // the state -- the opposite of the plain depolarizing family, which injects + // a physical X BEFORE measurement. So its measurement maps to neo's + // record-flipping channel (`with_p_meas`), which also carries the + // asymmetric `p_meas_0` (0->1) / `p_meas_1` (1->0) bias one-to-one. Gate + // and prep noise are ordinary uniform depolarizing. + let biased = |p_prep: f64, p_meas_0: f64, p_meas_1: f64, p1: f64, p2: f64| { + GeneralNoiseModelBuilder::new() + .with_p_prep(p_prep) + .with_p_meas(p_meas_0, p_meas_1) + .with_p1(p1) + .with_p2(p2) + }; + + if noise.downcast_ref::().is_some() + || noise + .downcast_ref::() + .is_some() + { + return Ok(None); + } + if let Some(depolarizing) = noise.downcast_ref::() { + let p = depolarizing.p; + return Ok(Some(uniform(p, p, p, p))); + } + if let Some(builder) = noise.downcast_ref::() { + // Resolve the configured probabilities via the built model; this + // enforces the same all-probabilities-set requirement the engines + // path would. + let (p_prep, p_meas, p1, p2) = builder.clone().build().probabilities(); + return Ok(Some(uniform(p_prep, p_meas, p1, p2))); + } + if let Some(biased_noise) = noise.downcast_ref::() { + // `BiasedDepolarizingNoise { p }` builds `new_uniform(p)`: every rate is + // `p`, with symmetric measurement bias. + let p = biased_noise.p; + return Ok(Some(biased(p, p, p, p, p))); + } + if let Some(builder) = noise.downcast_ref::() { + let (p_prep, p_meas_0, p_meas_1, p1, p2) = builder.clone().build().probabilities(); + return Ok(Some(biased(p_prep, p_meas_0, p_meas_1, p1, p2))); + } + if let Some(builder) = noise.downcast_ref::() { + // The stored p1/p2 are already in standard depolarizing convention + // (the with_average_* setters convert on the way in), so they map + // one-to-one onto neo's builder. Angle-dependent two-qubit scaling and + // the spontaneous-emission ratios, if present, are translated below; + // everything else outside the simple Pauli subset is still rejected. + let Some((p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission, p2_emission)) = + builder.pauli_with_angle_scaling() + else { + return Err(PecosError::Input( + "This GeneralNoiseModel configuration uses features beyond the simple \ + probability subset (leakage, seepage, idle, crosstalk, scales, custom \ + emission distributions, or noiseless gates), which are not yet mapped to \ + the neo stack. Use .stack(SimStack::Engines) or configure sim_neo() \ + directly with a neo noise model." + .to_string(), + )); + }; + // Emission is gate-removing in both stacks with the default uniform + // emission distribution, so carrying the resolved ratios reproduces it + // exactly. The ratios are set unconditionally (with the engines-resolved + // values, defaults included) so neo cannot silently fall back to its own + // default emission fraction. (Locked by the facade emission differential + // test in `neo_emission_test.rs`.) + let mut neo_builder = GeneralNoiseModelBuilder::new() + .with_p_prep(p_prep) + .with_p_meas(p_meas_0, p_meas_1) + .with_p1(p1) + .with_p2(p2) + .with_p1_emission_ratio(p1_emission) + .with_p2_emission_ratio(p2_emission); + if let Some((a, b, c, d, power)) = angle { + // Engines' angle-dependent two-qubit error rate is + // p2 * (a*|theta/pi|^power + b) for theta < 0 + // p2 * (c*|theta/pi|^power + d) for theta > 0 + // (GeneralNoiseModel::p2_angle_error_rate). neo's AngleScaling + // evaluates offset + linear*|theta/pi| + scale*|theta/pi|^power per + // sign, so the engines coefficients map to scale (the power term) + // and the engines offsets map to offset, with the linear terms + // zero. This reproduces engines exactly, including the zero-angle + // (b+d)/2 average. (NOT AngleScaling::from_general_params, which is + // a different symmetric offset/linear/scale parameterization.) + // + // Both stacks read the gate angle as the SIGNED principal value + // (-pi, pi] -- neo via `to_radians_signed`, engines likewise after + // its noise call site was aligned with its gate unitaries -- so the + // sign-dependent coefficients agree cross-stack at every angle + // (locked by `gnm_angle_scaling_negative_matches`). + neo_builder = neo_builder + .with_p2_angle_scaling(AngleScaling::asymmetric(b, 0.0, a, d, 0.0, c, power)); + } + return Ok(Some(neo_builder)); + } + + Err(PecosError::Input( + "This noise type is not yet mapped to the neo stack (mapped so far: PassThroughNoise, \ + DepolarizingNoise, DepolarizingNoiseModelBuilder, BiasedDepolarizingNoise, \ + BiasedDepolarizingNoiseModelBuilder, GeneralNoiseModelBuilder's simple \ + probability subset). Remove .noise(), use .stack(SimStack::Engines), or configure \ + sim_neo() directly with a neo noise model." + .to_string(), + )) +} + +impl ProgrammedSimBuilder { /// Override the classical engine selection /// /// This allows you to specify a different engine than the auto-selected one. @@ -150,6 +541,7 @@ impl ProgrammedSimBuilder { /// Set the random seed (delegates to base builder) #[must_use] pub fn seed(mut self, seed: u64) -> Self { + self.routed.seed = Some(seed); self.base_builder = self.base_builder.seed(seed); self } @@ -157,6 +549,11 @@ impl ProgrammedSimBuilder { /// Set the number of worker threads (delegates to base builder) #[must_use] pub fn workers(mut self, workers: usize) -> Self { + // Last-writer-wins, matching the engines `SimBuilder`: an explicit + // count clears a prior `.auto_workers()` so the two never both apply on + // the neo route (where they are resolved separately). + self.routed.workers = Some(workers); + self.routed.auto_workers = false; self.base_builder = self.base_builder.workers(workers); self } @@ -164,6 +561,8 @@ impl ProgrammedSimBuilder { /// Use automatic worker count (delegates to base builder) #[must_use] pub fn auto_workers(mut self) -> Self { + self.routed.auto_workers = true; + self.routed.workers = None; self.base_builder = self.base_builder.auto_workers(); self } @@ -179,8 +578,9 @@ impl ProgrammedSimBuilder { #[must_use] pub fn noise(mut self, noise_builder: N) -> Self where - N: pecos_engines::noise::IntoNoiseModel + Send + 'static, + N: pecos_engines::noise::IntoNoiseModel + Clone + Send + 'static, { + self.routed.noise = Some(Box::new(noise_builder.clone())); self.base_builder = self.base_builder.noise(noise_builder); self } @@ -192,6 +592,7 @@ impl ProgrammedSimBuilder { Q: pecos_engines::quantum_engine_builder::IntoQuantumEngineBuilder + 'static, Q::Builder: Send + 'static, { + self.routed.quantum_set = true; self.base_builder = self.base_builder.quantum(quantum_builder); self } @@ -199,6 +600,7 @@ impl ProgrammedSimBuilder { /// Set the number of qubits (delegates to base builder) #[must_use] pub fn qubits(mut self, num_qubits: usize) -> Self { + self.routed.qubits = Some(num_qubits); self.base_builder = self.base_builder.qubits(num_qubits); self } @@ -229,7 +631,8 @@ impl ProgrammedSimBuilder { /// .quantum(sparse_stab()) /// .noise(DepolarizingNoise { p: 0.01 }) /// .seed(42) -/// .run(100)?; +/// .shots(100) +/// .run()?; /// # Ok::<(), pecos_core::errors::PecosError>(()) /// ``` pub fn sim>(program: P) -> ProgrammedSimBuilder { @@ -237,5 +640,7 @@ pub fn sim>(program: P) -> ProgrammedSimBuilder { base_builder: sim_builder(), program: program.into(), override_classical: false, + stack: SimStack::default(), + routed: RoutedConfig::default(), } } diff --git a/crates/pecos/tests/neo_emission_test.rs b/crates/pecos/tests/neo_emission_test.rs new file mode 100644 index 000000000..aedca3019 --- /dev/null +++ b/crates/pecos/tests/neo_emission_test.rs @@ -0,0 +1,285 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Differential test for the `GeneralNoiseModel` EMISSION channel across stacks. +//! +//! Engines models spontaneous emission as REPLACING the gate (the gate is +//! dropped on an emission fault). neo now matches this by undoing the gate +//! (applying its dagger) before the emission error. Each test pins three +//! configurations against the gate-removing analytic and against one another: +//! engines, neo configured DIRECTLY via `sim_neo`, and neo reached through the +//! `sim().stack(Neo)` FACADE (which now maps the engines emission ratios onto +//! neo's builder). +//! +//! Analytic for `x; measure` with uniform Pauli and emission weights, gate +//! error `p1` and emission ratio `e`: an emission fault (probability `p1*e`) +//! drops the X so the qubit is `|0>` and a uniform Pauli reads `0` only on Z +//! (`P(0) = 1/3`); a Pauli fault (probability `p1*(1-e)`) keeps the X so the +//! qubit is `|1>` and a uniform Pauli reads `0` on X or Y (`P(0) = 2/3`). Hence +//! `P(0) = p1 * (e/3 + (1-e)*2/3)`. At `p1 = 0.3`, `e = 0.5` this is `0.15` (the +//! gate-PRESERVING model would give `0.2`). + +#![cfg(feature = "neo")] + +use pecos::{SimStack, sim}; +use pecos_num::jeffreys_interval; +use pecos_programs::Qasm; + +const SHOTS: usize = 20_000; +const CONFIDENCE: f64 = 0.99999; +const P1: f64 = 0.3; +const EMISSION: f64 = 0.5; + +const X_MEASURE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + x q[0]; + measure q[0] -> c[0]; +"#; + +#[allow(clippy::cast_precision_loss)] +fn rate_zero(shots: &pecos_engines::shot_results::ShotVec) -> (u64, f64) { + let zeros = shots + .shots + .iter() + .filter(|s| s.data["c"].to_bitstring().as_deref() == Some("0")) + .count() as u64; + (zeros, zeros as f64 / SHOTS as f64) +} + +/// The single-qubit emission noise: gate error `p1`, emission ratio +/// `EMISSION`, everything else (prep, meas, p2, leakage, idle) zeroed so the +/// only physics is the single-qubit emission/Pauli channel on the X gate. A +/// fresh builder each call since `.noise()` consumes it. +fn emission_noise_1q() -> pecos_engines::noise::GeneralNoiseModelBuilder { + pecos_engines::noise::GeneralNoiseModel::builder() + .with_p1_probability(P1) + .with_p1_emission_ratio(EMISSION) + .with_p2_probability(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) +} + +fn engines_zero_count() -> u64 { + let results = sim(Qasm::from_string(X_MEASURE)) + .stack(SimStack::Engines) + .noise(emission_noise_1q()) + .seed(42) + .shots(SHOTS) + .run() + .expect("engines run"); + rate_zero(&results).0 +} + +/// neo reached through the `sim().stack(Neo)` FACADE with the SAME engines +/// `GeneralNoiseModel`: the facade maps the emission ratio onto neo's builder. +fn neo_facade_zero_count() -> u64 { + let results = sim(Qasm::from_string(X_MEASURE)) + .stack(SimStack::Neo) + .noise(emission_noise_1q()) + .seed(7) // independent seed; agreement must be physical + .shots(SHOTS) + .run() + .expect("neo facade run"); + rate_zero(&results).0 +} + +/// neo configured directly (the facade does not map emission yet): the +/// `GeneralNoiseModelBuilder` mirrors the same single-qubit emission channel. +fn neo_zero_count() -> u64 { + use pecos_neo::noise::GeneralNoiseModelBuilder; + use pecos_neo::tool::{monte_carlo, sim_neo}; + + let noise = GeneralNoiseModelBuilder::new() + .with_p1(P1) + .with_p1_emission_ratio(EMISSION) + .with_p2(0.0) + .with_p_prep(0.0) + .with_p_meas_symmetric(0.0); + let results = sim_neo(Qasm::from_string(X_MEASURE)) + .auto() + .sampling(monte_carlo(SHOTS)) + .noise(noise) + .seed(99) // independent of the engines seed; agreement must be physical + .run(); + let shots = results.shots.expect("neo produced shots"); + rate_zero(&shots).0 +} + +#[test] +fn emission_is_gate_removing_and_matches_engines() { + let analytic = P1 * (EMISSION / 3.0 + (1.0 - EMISSION) * 2.0 / 3.0); // 0.15 + + let engines = engines_zero_count(); + let neo = neo_zero_count(); + let facade = neo_facade_zero_count(); + let engines_ci = jeffreys_interval(engines, SHOTS as u64, CONFIDENCE); + let neo_ci = jeffreys_interval(neo, SHOTS as u64, CONFIDENCE); + let facade_ci = jeffreys_interval(facade, SHOTS as u64, CONFIDENCE); + println!( + "emission: engines {engines}/{SHOTS} CI [{:.4}, {:.4}], neo-direct {neo}/{SHOTS} CI \ + [{:.4}, {:.4}], neo-facade {facade}/{SHOTS} CI [{:.4}, {:.4}], gate-removing analytic \ + {analytic:.4} (gate-preserving would be {:.4})", + engines_ci.0, + engines_ci.1, + neo_ci.0, + neo_ci.1, + facade_ci.0, + facade_ci.1, + P1 * 2.0 / 3.0 + ); + + // All three configurations contain the gate-REMOVING analytic (0.15), + // proving the gate is dropped on emission; the gate-PRESERVING value (0.2) + // is excluded. The facade route additionally proves the engines->neo + // emission-ratio mapping is wired through `sim().stack(Neo)`. + for (name, ci) in [ + ("engines", engines_ci), + ("neo-direct", neo_ci), + ("neo-facade", facade_ci), + ] { + assert!( + ci.0 <= analytic && analytic <= ci.1, + "{name} P(0) excludes the gate-removing analytic {analytic}" + ); + } + // And every pair of stacks agrees. + assert!( + engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, + "engines and neo-direct emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" + ); + assert!( + engines_ci.0 <= facade_ci.1 && facade_ci.0 <= engines_ci.1, + "engines and neo-facade emission rates disagree: {engines}/{SHOTS} vs {facade}/{SHOTS}" + ); +} + +// --- Two-qubit emission --------------------------------------------------- + +const P2: f64 = 0.6; + +/// `x q0; cx q0,q1; measure q1`. Pure two-qubit emission (`emission=1.0`, +/// `p1=0`) on the CX. If the CX is DROPPED, q1 stays 0 and a uniform two-qubit +/// Pauli flips it on 8/15 -> `P(q1=0) = p2 * 7/15`. If the CX is KEPT, q1 is 1 +/// and the Pauli flips it on 8/15 -> `P(q1=0) = p2 * 8/15`. At `p2=0.6` that is +/// `0.28` (gate-removing) vs `0.32` (gate-preserving). +const CX_MEASURE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[1]; + x q[0]; + cx q[0], q[1]; + measure q[1] -> c[0]; +"#; + +fn emission_noise_2q() -> pecos_engines::noise::GeneralNoiseModelBuilder { + pecos_engines::noise::GeneralNoiseModel::builder() + .with_p1_probability(0.0) + .with_p2_probability(P2) + .with_p2_emission_ratio(1.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) +} + +fn engines_2q_zero_count() -> u64 { + let results = sim(Qasm::from_string(CX_MEASURE)) + .stack(SimStack::Engines) + .noise(emission_noise_2q()) + .seed(42) + .shots(SHOTS) + .run() + .expect("engines run"); + rate_zero(&results).0 +} + +fn neo_facade_2q_zero_count() -> u64 { + let results = sim(Qasm::from_string(CX_MEASURE)) + .stack(SimStack::Neo) + .noise(emission_noise_2q()) + .seed(7) + .shots(SHOTS) + .run() + .expect("neo facade run"); + rate_zero(&results).0 +} + +fn neo_2q_zero_count() -> u64 { + use pecos_neo::noise::GeneralNoiseModelBuilder; + use pecos_neo::tool::{monte_carlo, sim_neo}; + + let noise = GeneralNoiseModelBuilder::new() + .with_p1(0.0) + .with_p2(P2) + .with_p2_emission_ratio(1.0) + .with_p_prep(0.0) + .with_p_meas_symmetric(0.0); + let results = sim_neo(Qasm::from_string(CX_MEASURE)) + .auto() + .sampling(monte_carlo(SHOTS)) + .noise(noise) + .seed(99) + .run(); + let shots = results.shots.expect("neo produced shots"); + rate_zero(&shots).0 +} + +#[test] +fn two_qubit_emission_is_gate_removing_and_matches_engines() { + let analytic = P2 * 7.0 / 15.0; // 0.28 (gate-preserving would be P2*8/15 = 0.32) + + let engines = engines_2q_zero_count(); + let neo = neo_2q_zero_count(); + let facade = neo_facade_2q_zero_count(); + let engines_ci = jeffreys_interval(engines, SHOTS as u64, CONFIDENCE); + let neo_ci = jeffreys_interval(neo, SHOTS as u64, CONFIDENCE); + let facade_ci = jeffreys_interval(facade, SHOTS as u64, CONFIDENCE); + println!( + "2q emission: engines {engines}/{SHOTS} CI [{:.4}, {:.4}], neo-direct {neo}/{SHOTS} CI \ + [{:.4}, {:.4}], neo-facade {facade}/{SHOTS} CI [{:.4}, {:.4}], gate-removing analytic \ + {analytic:.4} (gate-preserving would be {:.4})", + engines_ci.0, + engines_ci.1, + neo_ci.0, + neo_ci.1, + facade_ci.0, + facade_ci.1, + P2 * 8.0 / 15.0 + ); + + for (name, ci) in [ + ("engines", engines_ci), + ("neo-direct", neo_ci), + ("neo-facade", facade_ci), + ] { + assert!( + ci.0 <= analytic && analytic <= ci.1, + "{name} P(q1=0) excludes the gate-removing analytic {analytic}" + ); + } + assert!( + engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, + "engines and neo-direct 2q emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" + ); + assert!( + engines_ci.0 <= facade_ci.1 && facade_ci.0 <= engines_ci.1, + "engines and neo-facade 2q emission rates disagree: {engines}/{SHOTS} vs {facade}/{SHOTS}" + ); +} diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs new file mode 100644 index 000000000..4ab1fc34f --- /dev/null +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -0,0 +1,519 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Statistical equivalence matrix between the engines and neo stacks +//! (validation-gate item V1). +//! +//! Each cell runs the same QASM program with the same mapped noise +//! configuration through `sim()` on both stacks and compares the target +//! outcome rate with Jeffreys credible intervals: the two stacks' +//! intervals must overlap, and where the rate has an exact analytic +//! value, each stack's interval must contain it. +//! +//! Program-type coverage beyond QASM (HUGR) and exact worker-count +//! invariance are covered by `neo_routing_test.rs`; surface-code-scale +//! decoded equivalence is covered by `neo_surface_ler_test.rs`. + +#![cfg(feature = "neo")] + +use pecos::{SimStack, sim}; +use pecos_engines::shot_results::ShotVec; +use pecos_num::jeffreys_interval; +use pecos_programs::Qasm; + +const SHOTS: usize = 20_000; +/// ~4.4 sigma per side: stack disagreement, not sampling noise, is what +/// fails a cell. +const CONFIDENCE: f64 = 0.99999; +const SEED: u64 = 42; + +const X_MEASURE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + x q[0]; + measure q[0] -> c[0]; +"#; + +const RESET_MEASURE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + reset q[0]; + measure q[0] -> c[0]; +"#; + +const MEASURE_TWICE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + reset q[0]; + measure q[0] -> c[0]; + measure q[0] -> c[0]; +"#; + +const BELL: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + h q[0]; + cx q[0],q[1]; + measure q[0] -> c[0]; + measure q[1] -> c[1]; +"#; + +const FEEDBACK: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + x q[0]; + measure q[0] -> c[0]; + if (c == 1) x q[1]; + measure q[1] -> c[1]; +"#; + +/// RZZ at +pi/2 on |00> (a ZZ=+1 eigenstate): the gate leaves the state in +/// |00> (up to phase), so the only outcome change is from the angle-scaled +/// two-qubit depolarizing noise on the RZZ. The 8 of 15 non-identity Paulis +/// that anticommute with Z(x)Z flip the parity, giving `P(01 or 10) = 8*p_eff/15`. +const RZZ_POS: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + rzz(0.5*pi) q[0],q[1]; + measure q[0] -> c[0]; + measure q[1] -> c[1]; +"#; + +/// Same as `RZZ_POS` but at -pi/2, exercising the NEGATIVE-angle branch of the +/// asymmetric scaling (engines `(a, b)` / neo `neg_*`). +const RZZ_NEG: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + rzz(-0.5*pi) q[0],q[1]; + measure q[0] -> c[0]; + measure q[1] -> c[1]; +"#; + +/// One noise configuration of the matrix, applied identically to both +/// stacks (the facade maps it to neo's noise channels). +enum NoiseCell { + Meas(f64), + Prep(f64), + P1(f64), + P2(f64), + Uniform(f64), + GnmSimple { + average_p1: f64, + p_meas: f64, + }, + /// `GeneralNoiseModel` two-qubit noise with angle-dependent scaling: stored + /// `p2`, asymmetric coefficients `(a, b, c, d)` and `power`. Everything + /// outside the two-qubit channel is zeroed so the physics is exactly the + /// angle-scaled depolarizing channel on the RZZ gate. + GnmAngle { + p2: f64, + angle_params: (f64, f64, f64, f64), + angle_power: f64, + }, + /// `BiasedDepolarizingNoiseModel` with zero gate/prep noise and asymmetric + /// record-flip measurement: `p_meas_0` flips a 0 outcome to 1, `p_meas_1` + /// flips a 1 outcome to 0. The bias is applied to the recorded outcome + /// after readout (never the state), so it must map to neo's record-flip + /// channel, not the state-flip one. + BiasedMeas { + p_meas_0: f64, + p_meas_1: f64, + }, +} + +impl NoiseCell { + fn run(&self, qasm: &str, stack: SimStack) -> ShotVec { + // Independent seed per stack. Each cell compares the two stacks' + // empirical rates (Jeffreys overlap), so the comparison must be + // between INDEPENDENT samples — a shared seed would make it + // tautological if the two stacks' per-shot RNG streams ever + // converged. Each stack is also checked against its analytic value, + // which holds for any seed. + let seed = if matches!(stack, SimStack::Neo) { + SEED ^ 0xA5A5 + } else { + SEED + }; + let builder = sim(Qasm::from_string(qasm)).stack(stack).seed(seed); + let depol = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { + pecos_engines::noise::DepolarizingNoiseModel::builder() + .with_prep_probability(p_prep) + .with_meas_probability(p_meas) + .with_p1_probability(p1) + .with_p2_probability(p2) + }; + let results = match *self { + Self::Meas(p) => builder.noise(depol(0.0, p, 0.0, 0.0)).shots(SHOTS).run(), + Self::Prep(p) => builder.noise(depol(p, 0.0, 0.0, 0.0)).shots(SHOTS).run(), + Self::P1(p) => builder.noise(depol(0.0, 0.0, p, 0.0)).shots(SHOTS).run(), + Self::P2(p) => builder.noise(depol(0.0, 0.0, 0.0, p)).shots(SHOTS).run(), + Self::Uniform(p) => builder + .noise(pecos_engines::DepolarizingNoise { p }) + .shots(SHOTS) + .run(), + Self::GnmSimple { average_p1, p_meas } => builder + .noise( + // GeneralNoiseModel has realistic non-zero defaults; + // zero everything outside the simple Pauli subset so + // the cell physics is exactly known. + pecos_engines::noise::GeneralNoiseModel::builder() + .with_average_p1_probability(average_p1) + .with_average_p2_probability(0.0) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(p_meas) + .with_meas_1_probability(p_meas), + ) + .shots(SHOTS) + .run(), + Self::GnmAngle { + p2, + angle_params: (a, b, c, d), + angle_power, + } => builder + .noise( + // Plain Pauli two-qubit noise with angle scaling; zero + // every other channel and the non-neutral GNM defaults so + // only the angle-scaled RZZ depolarizing noise remains. + pecos_engines::noise::GeneralNoiseModel::builder() + .with_p2_probability(p2) + .with_p2_angle_params(a, b, c, d) + .with_p2_angle_power(angle_power) + .with_average_p1_probability(0.0) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0), + ) + .shots(SHOTS) + .run(), + Self::BiasedMeas { p_meas_0, p_meas_1 } => builder + .noise( + // Asymmetric record-flip measurement, no gate/prep noise. + pecos_engines::noise::BiasedDepolarizingNoiseModel::builder() + .with_prep_probability(0.0) + .with_meas_0_probability(p_meas_0) + .with_meas_1_probability(p_meas_1) + .with_single_qubit_probability(0.0) + .with_two_qubit_probability(0.0), + ) + .shots(SHOTS) + .run(), + }; + results.expect("simulation run") + } +} + +/// Count shots whose register `c` reads any of the target bitstrings. +/// +/// Every target set used here is symmetric under bit reversal, so the +/// count is independent of register bit ordering. +fn count_targets(results: &ShotVec, targets: &[&str]) -> u64 { + results + .shots + .iter() + .filter(|shot| { + let bits = shot.data["c"].to_bitstring().expect("c register bits"); + targets.contains(&bits.as_str()) + }) + .count() as u64 +} + +/// Run one matrix cell on both stacks and apply the equivalence (and +/// optional analytic-truth) assertions. +fn check_cell(name: &str, qasm: &str, cell: &NoiseCell, targets: &[&str], analytic: Option) { + let engines = count_targets(&cell.run(qasm, SimStack::Engines), targets); + let neo = count_targets(&cell.run(qasm, SimStack::Neo), targets); + + let engines_ci = jeffreys_interval(engines, SHOTS as u64, CONFIDENCE); + let neo_ci = jeffreys_interval(neo, SHOTS as u64, CONFIDENCE); + println!( + "{name}: engines {engines}/{SHOTS} CI [{:.5}, {:.5}], \ + neo {neo}/{SHOTS} CI [{:.5}, {:.5}], analytic {analytic:?}", + engines_ci.0, engines_ci.1, neo_ci.0, neo_ci.1 + ); + + assert!( + engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, + "{name}: stack rates are statistically incompatible: \ + engines {engines}/{SHOTS} vs neo {neo}/{SHOTS}" + ); + + if let Some(truth) = analytic { + assert!( + engines_ci.0 <= truth && truth <= engines_ci.1, + "{name}: engines rate {engines}/{SHOTS} excludes the analytic value {truth}" + ); + assert!( + neo_ci.0 <= truth && truth <= neo_ci.1, + "{name}: neo rate {neo}/{SHOTS} excludes the analytic value {truth}" + ); + } +} + +#[test] +fn meas_only_rates_match() { + // Measurement flip only: P(c = 0 after X) = p_meas exactly. + check_cell( + "meas_only", + X_MEASURE, + &NoiseCell::Meas(0.2), + &["0"], + Some(0.2), + ); +} + +#[test] +fn prep_only_rates_match() { + // Preparation error only: P(c = 1 after reset) = p_prep exactly. + check_cell( + "prep_only", + RESET_MEASURE, + &NoiseCell::Prep(0.15), + &["1"], + Some(0.15), + ); +} + +#[test] +fn p1_only_rates_match() { + // Uniform 1q depolarizing after the X gate: X and Y flip the Z-basis + // outcome, Z does not, so P(c = 0) = 2p/3 exactly. + check_cell("p1_only", X_MEASURE, &NoiseCell::P1(0.3), &["0"], Some(0.2)); +} + +#[test] +fn p2_only_anticorrelation_matches() { + // Uniform 2q depolarizing after the Bell CX: of the 15 two-qubit + // Paulis, the 8 with exactly one X/Y factor anticommute with Z(x)Z + // and break the outcome correlation, so P(01 or 10) = 8p/15 exactly. + check_cell( + "p2_only", + BELL, + &NoiseCell::P2(0.3), + &["01", "10"], + Some(8.0 * 0.3 / 15.0), + ); +} + +#[test] +fn uniform_depolarizing_compound_matches() { + // All channels at p = 0.1 on the x-measure program: the compound + // error rate has no simple closed form; cross-stack agreement only. + check_cell( + "uniform_depol", + X_MEASURE, + &NoiseCell::Uniform(0.1), + &["0"], + None, + ); +} + +#[test] +fn gnm_simple_subset_matches() { + // GeneralNoiseModel's "average" convention stores p1 = 1.5 x average + // (0.3 here, flip 0.2) on both stacks, composed with a 5% measurement + // flip: P(c = 0) = 0.2 * 0.95 + 0.8 * 0.05 = 0.23 exactly. + check_cell( + "gnm_simple", + X_MEASURE, + &NoiseCell::GnmSimple { + average_p1: 0.2, + p_meas: 0.05, + }, + &["0"], + Some(0.23), + ); +} + +#[test] +fn feedback_under_measurement_noise_matches() { + // Conditional feedback with noisy measurement: the recorded c[0] + // drives the correction, so both stacks must apply the measurement + // flip with the same record-vs-state semantics to agree here. + check_cell( + "feedback_meas_noise", + FEEDBACK, + &NoiseCell::Meas(0.1), + &["11"], + None, + ); +} + +#[test] +fn meas_twice_without_reset_matches() { + // Measurement noise in the depolarizing family is a physical X + // injected before readout, so the error persists in the state: the + // SECOND measurement of an un-reset qubit flips at 2p(1-p), not p. + // The creg bit is overwritten, so c reads the second outcome. A + // record-flip mapping (the bug this cell guards against) would + // produce p here. + let p = 0.25; + check_cell( + "meas_twice", + MEASURE_TWICE, + &NoiseCell::Meas(p), + &["1"], + Some(2.0 * p * (1.0 - p)), + ); +} + +#[test] +fn meas_twice_gnm_is_record_flip_not_state_flip() { + // The COMPLEMENT of `meas_twice_without_reset_matches`, locking the + // OTHER engines measurement convention. GeneralNoiseModel readout error + // flips only the recorded outcome, never the post-measurement state, so + // the qubit stays |0> across both measurements and the SECOND outcome + // flips at exactly p (record flip) — NOT 2p(1-p) (state flip). Both + // stacks must agree (engines GNM record-flip maps to neo's record- + // flipping MeasurementChannel). Together with the depolarizing cell + // above, this pins the engines depolarizing-vs-GNM measurement-physics + // distinction (the B1 root cause) on BOTH sides, cross-stack. + let p = 0.25; + check_cell( + "meas_twice_gnm", + MEASURE_TWICE, + &NoiseCell::GnmSimple { + average_p1: 0.0, + p_meas: p, + }, + &["1"], + Some(p), + ); +} + +/// One `GnmAngle` configuration shared by both angle cells: stored p2 = 0.3, +/// asymmetric coefficients with the NEGATIVE branch (a = 1.5) steeper than the +/// POSITIVE branch (c = 1.0), linear power. The facade translates engines' +/// `(a, b, c, d, power)` into neo's asymmetric `AngleScaling`; both cells pin +/// the cross-stack rate AND the analytic value, so a dropped or neg/pos-swapped +/// mapping fails loudly. +const ANGLE_CELL: NoiseCell = NoiseCell::GnmAngle { + p2: 0.3, + angle_params: (1.5, 0.0, 1.0, 0.0), + angle_power: 1.0, +}; + +#[test] +fn gnm_angle_scaling_positive_matches() { + // RZZ(+pi/2): the POSITIVE branch scales p2 by c*|theta/pi|^power + d = + // 1.0*0.5 + 0 = 0.5, so the effective p2 is 0.3*0.5 = 0.15 and the 8/15 + // parity-flipping Paulis give P(01 or 10) = 8*0.15/15 = 0.08. Dropping the + // angle scaling (the pre-mapping behavior) would give the unscaled + // 8*0.3/15 = 0.16, and the neg/pos-swapped mapping would give 0.12 — both + // far outside the band, so this discriminates. + check_cell( + "gnm_angle_pos", + RZZ_POS, + &ANGLE_CELL, + &["01", "10"], + Some(8.0 * (0.3 * 0.5) / 15.0), + ); +} + +#[test] +fn gnm_angle_scaling_negative_matches() { + // RZZ(-pi/2): the NEGATIVE branch scales p2 by a*|theta/pi|^power + b = + // 1.5*0.5 + 0 = 0.75, so the effective p2 is 0.3*0.75 = 0.225 and the rate + // is 8*0.225/15 = 0.12. Both stacks read the gate angle as the SIGNED + // principal value (-pi, pi] -- neo always did; engines' noise call site was + // aligned with its own gate unitaries (which all use `to_radians_signed`), + // fixing a bug where the unsigned [0, 2pi) angle made RZZ(-pi/2) take the + // POSITIVE branch as 3pi/2 and never reached the negative coefficients. So + // the stacks AGREE here, and the value differs from the positive cell + // (0.08), locking the asymmetry direction. A regression to the unsigned + // angle would push engines to 8*0.45/15 = 0.24 and fail the cross-stack + // overlap and the analytic containment. + check_cell( + "gnm_angle_neg", + RZZ_NEG, + &ANGLE_CELL, + &["01", "10"], + Some(8.0 * (0.3 * 0.75) / 15.0), + ); +} + +#[test] +fn biased_meas_flip_1_to_0_matches() { + // BiasedDepolarizing flips the RECORDED outcome after readout. A |1> (from + // X, with gate noise zeroed) reads 0 with probability p_meas_1 (the 1->0 + // rate). The bias is asymmetric (p_meas_0 = 0.1 != p_meas_1 = 0.3), so a + // swapped 0<->1 mapping would read 0.1 here instead of 0.3. + check_cell( + "biased_meas_1to0", + X_MEASURE, + &NoiseCell::BiasedMeas { + p_meas_0: 0.1, + p_meas_1: 0.3, + }, + &["0"], + Some(0.3), + ); +} + +#[test] +fn biased_meas_flip_0_to_1_matches() { + // The complementary direction: a |0> (from reset) reads 1 with probability + // p_meas_0 (the 0->1 rate, 0.1). Together with the cell above this pins + // both the magnitude AND the direction of the asymmetric bias. + check_cell( + "biased_meas_0to1", + RESET_MEASURE, + &NoiseCell::BiasedMeas { + p_meas_0: 0.1, + p_meas_1: 0.3, + }, + &["1"], + Some(0.1), + ); +} + +#[test] +fn biased_meas_twice_is_record_flip_not_state_flip() { + // Like meas_twice_gnm: BiasedDepolarizing flips the record, never the + // state, so a qubit reset to |0> and measured twice reads 1 on the SECOND + // measurement at exactly p_meas_0 = 0.25 -- NOT 2p(1-p) = 0.375 (state + // flip). This pins that BiasedDepolarizing maps to neo's record-flip + // channel (with_p_meas), not the state-flip channel the plain + // depolarizing family uses. + let p = 0.25; + check_cell( + "biased_meas_twice", + MEASURE_TWICE, + &NoiseCell::BiasedMeas { + p_meas_0: p, + p_meas_1: p, + }, + &["1"], + Some(p), + ); +} diff --git a/crates/pecos/tests/neo_hugr_routing_test.rs b/crates/pecos/tests/neo_hugr_routing_test.rs new file mode 100644 index 000000000..add224e1f --- /dev/null +++ b/crates/pecos/tests/neo_hugr_routing_test.rs @@ -0,0 +1,164 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! HUGR program routing to the neo stack. +//! +//! The neo stack runs HUGR through the PHIR engine (HUGR -> PHIR), so its +//! results use the same NAMED classical register contract (`c`) as the +//! engines/QASM path -- not the per-qubit `q0`/`q1` + `measurements` shape that +//! `pecos_hugr::hugr_engine` would emit -- and needs no Selene/LLVM. These +//! tests lock that, using the Guppy-generated fixtures shared with +//! `hugr_execution_tests.rs`, and cross-check against the engines PHIR engine. + +#![cfg(feature = "neo")] + +use std::collections::BTreeSet; + +use pecos::prelude::Data; +use pecos::{SimStack, sim}; +use pecos_programs::Hugr; + +/// Run a HUGR fixture on the neo stack and return the `c` register of each shot. +/// Panics if a shot has no `c` register (the contract this test guards). +fn neo_hugr_c(bytes: &[u8], seed: u64, shots: usize) -> Vec { + let results = sim(Hugr::from_bytes(bytes.to_vec())) + .stack(SimStack::Neo) + .seed(seed) + .shots(shots) + .run() + .expect("neo HUGR run"); + results + .shots + .iter() + .map(|shot| { + shot.data + .get("c") + .and_then(Data::as_u32) + .expect("neo HUGR results must expose the named `c` register") + }) + .collect() +} + +/// The engines-side reference: the same HUGR through `pecos_phir`'s engine on +/// the engines `sim_builder` (the `hugr_execution_tests.rs` path). +fn engines_phir_c(bytes: &[u8], seed: u64, shots: usize) -> Vec { + let builder = pecos_phir::phir_engine() + .from_hugr_bytes(bytes) + .expect("HUGR -> PhirEngineBuilder"); + let results = pecos_engines::sim_builder() + .classical(builder) + .seed(seed) + .run(shots) + .expect("engines PHIR run"); + results + .shots + .iter() + .filter_map(|shot| shot.data.get("c").and_then(Data::as_u32)) + .collect() +} + +#[test] +fn neo_hugr_results_use_the_named_c_register() { + // The contract: the only register key is `c`, NOT `q0`/`q1`/`measurements`. + let bytes = include_bytes!("test_data/hugr/bell_state.hugr"); + let results = sim(Hugr::from_bytes(bytes.to_vec())) + .stack(SimStack::Neo) + .seed(42) + .shots(5) + .run() + .expect("neo HUGR run"); + let keys: Vec<&String> = results.shots[0].data.keys().collect(); + assert!( + results.shots[0].data.contains_key("c"), + "neo HUGR must expose `c`; got keys {keys:?}" + ); + assert!( + !results.shots[0].data.contains_key("measurements") + && !results.shots[0].data.contains_key("q0"), + "neo HUGR must NOT expose the raw q0/measurements shape; got keys {keys:?}" + ); +} + +#[test] +fn neo_hugr_bell_state_correlations() { + let results = neo_hugr_c(include_bytes!("test_data/hugr/bell_state.hugr"), 42, 200); + assert_eq!(results.len(), 200); + for &v in &results { + assert!(v == 0 || v == 3, "Bell on neo must be 00 or 11, got {v}"); + } + assert!(results.contains(&0), "expected some 00"); + assert!(results.contains(&3), "expected some 11"); +} + +#[test] +fn neo_hugr_ghz_state_correlations() { + let results = neo_hugr_c(include_bytes!("test_data/hugr/ghz_state.hugr"), 42, 200); + assert_eq!(results.len(), 200); + for &v in &results { + assert!(v == 0 || v == 7, "GHZ on neo must be 000 or 111, got {v}"); + } +} + +#[test] +fn neo_hugr_rz_x_is_deterministic() { + // Rz(pi)|0> stays |0>, X|0> -> |1>: result 0b10 = 2 every shot. + let results = neo_hugr_c(include_bytes!("test_data/hugr/rz_x.hugr"), 42, 20); + assert_eq!(results.len(), 20); + for &v in &results { + assert_eq!( + v, 2, + "Rz(pi)|0> + X|0> on neo should give 0b10 = 2, got {v}" + ); + } +} + +#[test] +fn neo_hugr_support_matches_engines_phir() { + // Cross-check the neo facade route against the engines PHIR engine (the + // hugr_execution_tests path): both run the HUGR via PHIR, so the outcome + // SUPPORT must agree. Independent seeds -- agreement is from the shared + // contract, not a shared RNG stream. + let bytes = include_bytes!("test_data/hugr/bell_state.hugr"); + let neo: BTreeSet = neo_hugr_c(bytes, 1, 400).into_iter().collect(); + let engines: BTreeSet = engines_phir_c(bytes, 2, 400).into_iter().collect(); + assert_eq!( + neo, engines, + "neo-facade HUGR and engines-PHIR HUGR must explore the same Bell support" + ); + assert_eq!(neo, BTreeSet::from([0, 3])); +} + +#[test] +fn neo_hugr_control_flow_is_rejected() { + // The PHIR HUGR converter is straight-line only. HUGR with classical + // control flow (loops, conditionals) must be REJECTED up front, not + // silently converted into empty/partial results that look like a + // successful run. Each of these fixtures compiles to a CFG with multiple + // basic blocks. + for fixture in [ + &include_bytes!("test_data/hugr/simple_while_loop.hugr")[..], + &include_bytes!("test_data/hugr/forloop_h_test.hugr")[..], + &include_bytes!("test_data/hugr/simple_conditional.hugr")[..], + &include_bytes!("test_data/hugr/conditional_x.hugr")[..], + ] { + let err = sim(Hugr::from_bytes(fixture.to_vec())) + .stack(SimStack::Neo) + .seed(42) + .shots(4) + .run() + .expect_err("control-flow HUGR must be rejected on the neo stack"); + assert!( + err.to_string().contains("classical control flow"), + "expected a control-flow rejection, got: {err}" + ); + } +} diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs new file mode 100644 index 000000000..abfd77804 --- /dev/null +++ b/crates/pecos/tests/neo_routing_test.rs @@ -0,0 +1,453 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Contract tests for routing `sim()` to the pecos-neo stack. +//! +//! The neo stack must return the same `ShotVec` contract as the engines +//! stack: for deterministic programs, results are compared for exact +//! equality across stacks. + +#![cfg(feature = "neo")] + +use pecos::{SimStack, monte_carlo, sim}; +use pecos_programs::Qasm; + +/// Deterministic program exercising measurement feedback: c ends as "11". +fn deterministic_conditional_qasm() -> Qasm { + Qasm::from_string( + r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + x q[0]; + measure q[0] -> c[0]; + if (c[0] == 1) x q[1]; + measure q[1] -> c[1]; + "#, + ) +} + +#[test] +fn neo_stack_matches_engines_for_deterministic_qasm() { + let engines = sim(deterministic_conditional_qasm()) + .stack(SimStack::Engines) + .seed(42) + .shots(5) + .run() + .expect("engines run"); + + let neo = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .seed(42) + .shots(5) + .run() + .expect("neo run"); + + assert_eq!(engines.shots.len(), 5); + assert_eq!( + engines, neo, + "Deterministic program must produce identical ShotVecs on both stacks" + ); + for shot in &neo.shots { + assert_eq!(shot.data["c"].to_bitstring().unwrap(), "11"); + } +} + +#[test] +fn neo_stack_parallel_matches_engines() { + let engines = sim(deterministic_conditional_qasm()) + .stack(SimStack::Engines) + .seed(7) + .workers(2) + .shots(6) + .run() + .expect("engines run"); + + let neo = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .seed(7) + .workers(2) + .shots(6) + .run() + .expect("neo run"); + + assert_eq!(engines, neo); +} + +#[test] +fn neo_stack_worker_count_invariant_for_noisy_program() { + // The neo determinism guarantee (V4): each shot's RNG is derived from its + // GLOBAL shot index, so a STOCHASTIC program's results must be bit-identical + // regardless of how the shots are split across workers. The depolarizing + // noise is what makes this a real RNG test -- a deterministic program would + // pass trivially. (Same seed throughout; only the worker count varies.) + let noise = pecos_engines::DepolarizingNoise { p: 0.3 }; + let run = |workers: usize| { + sim(x_measure_qasm()) + .stack(SimStack::Neo) + .noise(noise) + .seed(42) + .workers(workers) + .shots(128) + .run() + .expect("neo run") + }; + let w1 = run(1); + // Self-check: the noise must actually produce a MIX of outcomes, or + // worker-invariance would hold trivially (all shots identical). + let rate0 = rate_of(&w1, "0"); + assert!( + rate0 > 0.0 && rate0 < 1.0, + "noisy program should produce varied outcomes (got rate(0)={rate0}), \ + otherwise worker-invariance is vacuous" + ); + assert_eq!( + w1, + run(2), + "neo noisy results must be invariant to worker count (1 vs 2)" + ); + assert_eq!( + w1, + run(4), + "neo noisy results must be invariant to worker count (1 vs 4)" + ); +} + +#[test] +fn neo_stack_same_seed_is_reproducible() { + // V3 reproducibility: identical config + identical seed -> bit-identical + // ShotVec on neo across independent runs (a noisy program, so the RNG is + // genuinely exercised). + let noise = pecos_engines::DepolarizingNoise { p: 0.1 }; + let run = || { + sim(x_measure_qasm()) + .stack(SimStack::Neo) + .noise(noise) + .seed(123) + .shots(64) + .run() + .expect("neo run") + }; + assert_eq!( + run(), + run(), + "neo must reproduce identical results for a fixed seed" + ); +} + +/// One-qubit program whose only error source is what the noise model adds. +fn x_measure_qasm() -> Qasm { + Qasm::from_string( + r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + x q[0]; + measure q[0] -> c[0]; + "#, + ) +} + +/// Fraction of shots where register `c` reads the given bitstring. +#[allow(clippy::cast_precision_loss)] // shot counts are far below 2^52 +fn rate_of(results: &pecos_engines::shot_results::ShotVec, bits: &str) -> f64 { + let matching = results + .shots + .iter() + .filter(|shot| shot.data["c"].to_bitstring().as_deref() == Some(bits)) + .count(); + matching as f64 / results.shots.len() as f64 +} + +#[test] +fn neo_stack_measurement_noise_rate_matches_engines() { + // Measurement-only noise: P(c = 0) = p_meas exactly on both stacks. + let p_meas = 0.2; + let shots = 4000; + let noise = pecos_engines::noise::DepolarizingNoiseModel::builder() + .with_prep_probability(0.0) + .with_meas_probability(p_meas) + .with_p1_probability(0.0) + .with_p2_probability(0.0); + + let engines = sim(x_measure_qasm()) + .stack(SimStack::Engines) + .noise(noise.clone()) + .seed(42) + .shots(shots) + .run() + .expect("engines run"); + let neo = sim(x_measure_qasm()) + .stack(SimStack::Neo) + .noise(noise) + .seed(42) + .shots(shots) + .run() + .expect("neo run"); + + let engines_rate = rate_of(&engines, "0"); + let neo_rate = rate_of(&neo, "0"); + + // Bands: ~5 sigma for p=0.2 at 4000 shots is ~0.032. + assert!( + (engines_rate - p_meas).abs() < 0.035, + "engines rate {engines_rate} should be near {p_meas}" + ); + assert!( + (neo_rate - p_meas).abs() < 0.035, + "neo rate {neo_rate} should be near {p_meas}" + ); +} + +#[test] +fn neo_stack_uniform_depolarizing_rate_matches_engines() { + // Uniform depolarizing through the convenience struct: the compound + // error rate must agree across stacks. This is a direct stack-vs-stack + // comparison, so the two stacks use INDEPENDENT seeds — agreement must + // come from matching conventions, not from a shared RNG stream (which + // would make the check tautological if the streams ever converged). + let shots = 4000; + let run = |stack: SimStack| { + let seed = if matches!(stack, SimStack::Neo) { + 7 ^ 0xA5A5 + } else { + 7 + }; + sim(x_measure_qasm()) + .stack(stack) + .noise(pecos_engines::DepolarizingNoise { p: 0.1 }) + .seed(seed) + .shots(shots) + .run() + .expect("run") + }; + + let engines_rate = rate_of(&run(SimStack::Engines), "0"); + let neo_rate = rate_of(&run(SimStack::Neo), "0"); + + assert!( + (engines_rate - neo_rate).abs() < 0.035, + "compound error rates should agree: engines={engines_rate}, neo={neo_rate}" + ); +} + +#[test] +fn neo_stack_biased_depolarizing_struct_rate_matches_engines() { + // The BiasedDepolarizingNoise convenience struct (uniform p, with the + // biased family's record-flip measurement) must agree cross-stack through + // the facade mapping. Independent seeds, as above. + let shots = 4000; + let run = |stack: SimStack| { + let seed = if matches!(stack, SimStack::Neo) { + 7 ^ 0xA5A5 + } else { + 7 + }; + sim(x_measure_qasm()) + .stack(stack) + .noise(pecos_engines::BiasedDepolarizingNoise { p: 0.1 }) + .seed(seed) + .shots(shots) + .run() + .expect("run") + }; + + let engines_rate = rate_of(&run(SimStack::Engines), "0"); + let neo_rate = rate_of(&run(SimStack::Neo), "0"); + + assert!( + (engines_rate - neo_rate).abs() < 0.035, + "biased struct compound rates should agree: engines={engines_rate}, neo={neo_rate}" + ); +} + +#[test] +fn neo_stack_general_noise_average_convention_matches() { + // The critical convention test: engines' with_average_p1_probability + // stores p1 = 1.5 x average internally (standard depolarizing + // convention), which the mapping carries one-to-one to neo. With + // average_p1 = 0.2 the effective depolarizing p1 is 0.3, so the + // outcome flip rate on a single 1q gate is 2/3 x 0.3 = 0.2 on BOTH + // stacks. A convention mismatch (double- or un-scaled) would shift + // one stack's rate to ~0.13 or ~0.3 and fail loudly. + let shots = 4000; + let expected_flip = 0.2; + let run = |stack: SimStack| { + // GeneralNoiseModel defaults are realistic (nonzero emission, prep + // leak, idle, and base probabilities); zero everything except the + // 1q Pauli channel so the physics is plain depolarizing. + let noise = pecos_engines::noise::GeneralNoiseModel::builder() + .with_average_p1_probability(0.2) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0) + .with_average_p2_probability(0.0); + sim(x_measure_qasm()) + .stack(stack) + .noise(noise) + .seed(11) + .shots(shots) + .run() + .expect("run") + }; + + let engines_rate = rate_of(&run(SimStack::Engines), "0"); + let neo_rate = rate_of(&run(SimStack::Neo), "0"); + + assert!( + (engines_rate - expected_flip).abs() < 0.035, + "engines flip rate {engines_rate} should be near {expected_flip}" + ); + assert!( + (neo_rate - expected_flip).abs() < 0.035, + "neo flip rate {neo_rate} should be near {expected_flip}" + ); +} + +#[test] +fn neo_stack_rejects_unmapped_noise() { + // A bare GeneralNoiseModel keeps its realistic defaults for prep leak + // (0.5) and linear idling (0.001) — physics beyond the simple Pauli + // subset, so the mapping must refuse rather than silently change the + // model. (Spontaneous emission IS now mapped, so it is the prep-leak + // and idle defaults that force the rejection here.) + let general = + pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1_probability(0.01); + let err = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .noise(general) + .shots(5) + .run() + .expect_err("beyond-subset GeneralNoiseModel configs are not mapped"); + assert!( + err.to_string() + .contains("beyond the simple probability subset"), + "unexpected error: {err}" + ); +} + +#[test] +fn neo_stack_rejects_nonunit_emission_scale() { + // `with_emission_scale` multiplies the emission ratios at build() but the + // facade subset surfaces the RAW ratios, so a non-unit scale would map a + // DIFFERENT emission rate to neo than engines runs. The facade must reject + // it rather than silently diverge. (Codex batch-4 finding 1.) + let general = pecos_engines::noise::GeneralNoiseModel::builder() + .with_p1_probability(0.3) + .with_p1_emission_ratio(0.25) + .with_emission_scale(2.0) + .with_p2_probability(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + let err = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .noise(general) + .shots(5) + .run() + .expect_err("non-unit emission_scale must not be silently mapped to neo"); + assert!( + err.to_string() + .contains("beyond the simple probability subset"), + "unexpected error: {err}" + ); +} + +#[test] +fn neo_stack_rejects_unrouted_quantum_backend() { + let err = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .quantum(pecos_engines::state_vector()) + .shots(5) + .run() + .expect_err("explicit quantum backends are not yet routed"); + assert!(err.to_string().contains("not yet routed to the neo stack")); +} + +#[test] +fn neo_stack_rejects_build() { + let Err(err) = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .build() + else { + panic!("neo stack has no MonteCarloEngine; build() must error"); + }; + assert!(err.to_string().contains("MonteCarloEngine")); +} + +// --- Shared sampling vocabulary (.sampling(monte_carlo(n))) ---------------- + +/// The shared `monte_carlo()` run-spec drives BOTH stacks through the facade, +/// and `.shots(n)` is exactly its shorthand. A deterministic program lets us +/// assert exact `ShotVec` equality across the two spellings and the two stacks. +#[test] +fn facade_sampling_monte_carlo_drives_both_stacks() { + for stack in [SimStack::Engines, SimStack::Neo] { + let via_sampling = sim(deterministic_conditional_qasm()) + .stack(stack) + .seed(42) + .sampling(monte_carlo(5)) + .run() + .expect("sampling run"); + let via_shots = sim(deterministic_conditional_qasm()) + .stack(stack) + .seed(42) + .shots(5) + .run() + .expect("shots run"); + + assert_eq!(via_sampling.shots.len(), 5); + assert_eq!( + via_sampling, via_shots, + "{stack:?}: .sampling(monte_carlo(5)) must equal .shots(5)" + ); + for shot in &via_sampling.shots { + assert_eq!(shot.data["c"].to_bitstring().unwrap(), "11"); + } + } +} + +/// `monte_carlo(n).workers(w)` carries worker parallelism through the facade on +/// both stacks; the deterministic program's results are worker-count invariant. +#[test] +fn facade_sampling_workers_runs_parallel_on_both_stacks() { + for stack in [SimStack::Engines, SimStack::Neo] { + let serial = sim(deterministic_conditional_qasm()) + .stack(stack) + .seed(7) + .sampling(monte_carlo(6)) + .run() + .expect("serial run"); + let parallel = sim(deterministic_conditional_qasm()) + .stack(stack) + .seed(7) + .sampling(monte_carlo(6).workers(2)) + .run() + .expect("parallel run"); + + assert_eq!(parallel.shots.len(), 6); + // A deterministic program yields identical outcomes regardless of how + // shots are split across workers, on either stack. + assert_eq!( + serial, parallel, + "{stack:?}: worker count must not change deterministic results" + ); + } +} diff --git a/crates/pecos/tests/neo_surface_ler_test.rs b/crates/pecos/tests/neo_surface_ler_test.rs new file mode 100644 index 000000000..419751109 --- /dev/null +++ b/crates/pecos/tests/neo_surface_ler_test.rs @@ -0,0 +1,449 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Surface-code memory LER equivalence between the engines and neo stacks +//! (validation-gate item V5). +//! +//! Builds a rotated surface-code Z-memory experiment once, emitting the +//! same circuit as both a QASM program (run through `sim()` on each stack) +//! and a `TickCircuit` (fed to the Rust DEM builder for decoding). Both +//! stacks' samples are decoded with the same MWPM decoder against the +//! same DEM, and the logical error rates are compared with Jeffreys +//! credible intervals. + +#![cfg(feature = "neo")] + +use pecos::{SimStack, sim}; +use pecos_decoder_core::ObservableDecoder; +use pecos_engines::shot_results::ShotVec; +use pecos_fusion_blossom::FusionBlossomDecoder; +use pecos_num::jeffreys_interval; +use pecos_programs::Qasm; +use pecos_qec::SurfaceCode; +use pecos_qec::fault_tolerance::dem_builder::DemBuilder; +use pecos_quantum::{Attribute, TickCircuit, TickMeasRef}; +use std::fmt::Write as _; + +/// A surface-code memory experiment in both program representations. +struct MemoryExperiment { + qasm: String, + tick: TickCircuit, + /// Detector definitions as relative measurement records (Stim style: + /// record -k is the k-th most recent measurement). + detectors: Vec>, + /// The logical-Z observable as relative measurement records. + observable: Vec, + num_measurements: usize, + /// Classical registers in declaration order: (name, width). + registers: Vec<(String, usize)>, + /// Global measurement record index -> (register index, bit index). + record_map: Vec<(usize, usize)>, +} + +/// Build a rotated surface-code Z-memory experiment of the given distance, +/// emitting the identical circuit as QASM and as a `TickCircuit`. +/// +/// Mirrors `examples/surface/d3_fault_catalog_lookup.rs`: data qubits are +/// reset to |0>, each round prepares ancillas, runs a sequential +/// CX-per-check schedule (X checks via H-conjugated ancilla controls, +/// Z checks via data controls), and measures the ancillas; the experiment +/// ends with a transversal Z-basis data measurement. +fn build_surface_memory(distance: usize, rounds: usize) -> MemoryExperiment { + let code = SurfaceCode::rotated(distance).expect("valid distance"); + let num_data = code.num_data_qubits(); + let nx = code.num_x_stabilizers(); + let nz = code.num_z_stabilizers(); + let num_qubits = num_data + nx + nz; + let x_anc = |idx: usize| num_data + idx; + let z_anc = |idx: usize| num_data + nx + idx; + + let mut tick = TickCircuit::new(); + let mut body = String::new(); + let mut registers: Vec<(String, usize)> = Vec::new(); + let mut record_map: Vec<(usize, usize)> = Vec::new(); + + let data_qubits: Vec = (0..num_data).collect(); + let x_ancillas: Vec = (0..nx).map(x_anc).collect(); + let z_ancillas: Vec = (0..nz).map(z_anc).collect(); + + tick.tick().pz(&data_qubits); + for q in &data_qubits { + writeln!(body, "reset q[{q}];").unwrap(); + } + + let mut x_round: Vec> = Vec::with_capacity(rounds); + let mut z_round: Vec> = Vec::with_capacity(rounds); + + for round in 0..rounds { + tick.tick().pz(&x_ancillas); + tick.tick().pz(&z_ancillas); + for q in x_ancillas.iter().chain(&z_ancillas) { + writeln!(body, "reset q[{q}];").unwrap(); + } + + tick.tick().h(&x_ancillas); + for q in &x_ancillas { + writeln!(body, "h q[{q}];").unwrap(); + } + + for check in code.x_stabilizers() { + let anc = x_anc(check.index); + for data in check.qubits() { + tick.tick().cx(&[(anc, data)]); + writeln!(body, "cx q[{anc}],q[{data}];").unwrap(); + } + } + for check in code.z_stabilizers() { + let anc = z_anc(check.index); + for data in check.qubits() { + tick.tick().cx(&[(data, anc)]); + writeln!(body, "cx q[{data}],q[{anc}];").unwrap(); + } + } + + tick.tick().h(&x_ancillas); + for q in &x_ancillas { + writeln!(body, "h q[{q}];").unwrap(); + } + + let reg_idx = registers.len(); + registers.push((format!("s{round}"), nx + nz)); + x_round.push(tick.tick().mz(&x_ancillas)); + for (bit, q) in x_ancillas.iter().enumerate() { + writeln!(body, "measure q[{q}] -> s{round}[{bit}];").unwrap(); + record_map.push((reg_idx, bit)); + } + z_round.push(tick.tick().mz(&z_ancillas)); + for (offset, q) in z_ancillas.iter().enumerate() { + let bit = nx + offset; + writeln!(body, "measure q[{q}] -> s{round}[{bit}];").unwrap(); + record_map.push((reg_idx, bit)); + } + } + + let reg_idx = registers.len(); + registers.push(("f".to_string(), num_data)); + let final_data = tick.tick().mz(&data_qubits); + for (bit, q) in data_qubits.iter().enumerate() { + writeln!(body, "measure q[{q}] -> f[{bit}];").unwrap(); + record_map.push((reg_idx, bit)); + } + + let num_measurements = tick.num_measurements(); + assert_eq!( + record_map.len(), + num_measurements, + "QASM measurement emission must track TickCircuit records one-to-one" + ); + + // Detector definitions, identical to the fault-catalog example: + // first-round Z checks are deterministic for |0...0> initialization, + // consecutive rounds compare like checks, and the final round compares + // each Z check against the data measurements in its support. + let mut detectors: Vec> = Vec::new(); + for &meas_ref in &z_round[0] { + detectors.push(relative_records(num_measurements, &[meas_ref])); + } + for round in 1..rounds { + for (¤t, &previous) in x_round[round].iter().zip(&x_round[round - 1]) { + detectors.push(relative_records(num_measurements, &[current, previous])); + } + for (¤t, &previous) in z_round[round].iter().zip(&z_round[round - 1]) { + detectors.push(relative_records(num_measurements, &[current, previous])); + } + } + for check in code.z_stabilizers() { + let mut refs = vec![z_round[rounds - 1][check.index]]; + refs.extend(check.qubits().into_iter().map(|q| final_data[q])); + detectors.push(relative_records(num_measurements, &refs)); + } + + let logical_refs: Vec = code + .logical_z() + .data_qubits + .iter() + .map(|&q| final_data[q]) + .collect(); + let observable = relative_records(num_measurements, &logical_refs); + + tick.set_meta( + "num_measurements", + Attribute::String(num_measurements.to_string()), + ); + tick.set_meta("detectors", Attribute::String(records_json(&detectors))); + tick.set_meta( + "observables", + Attribute::String(records_json(std::slice::from_ref(&observable))), + ); + + let mut qasm = String::new(); + writeln!(qasm, "OPENQASM 2.0;").unwrap(); + writeln!(qasm, "include \"qelib1.inc\";").unwrap(); + writeln!(qasm, "qreg q[{num_qubits}];").unwrap(); + for (name, width) in ®isters { + writeln!(qasm, "creg {name}[{width}];").unwrap(); + } + qasm.push_str(&body); + + MemoryExperiment { + qasm, + tick, + detectors, + observable, + num_measurements, + registers, + record_map, + } +} + +fn relative_records(num_measurements: usize, refs: &[TickMeasRef]) -> Vec { + let num_measurements = i32::try_from(num_measurements).expect("measurement count fits in i32"); + refs.iter() + .map(|m| i32::try_from(m.record_idx).expect("record index fits in i32") - num_measurements) + .collect() +} + +fn records_json(records: &[Vec]) -> String { + let entries: Vec = records + .iter() + .enumerate() + .map(|(id, rs)| { + let values = rs.iter().map(i32::to_string).collect::>().join(","); + format!(r#"{{"id":{id},"records":[{values}]}}"#) + }) + .collect(); + format!("[{}]", entries.join(",")) +} + +/// Extract the flat measurement-record bits of one shot. +fn shot_record_bits( + shot: &pecos_engines::shot_results::Shot, + experiment: &MemoryExperiment, +) -> Vec { + let register_bit = |reg: usize, bit: usize| -> u8 { + let (name, _) = &experiment.registers[reg]; + let data = &shot.data[name.as_str()]; + match data { + pecos_engines::shot_results::Data::BitVec(bv) => u8::from(bv[bit]), + pecos_engines::shot_results::Data::U8(v) => u8::from((v >> bit) & 1 == 1), + pecos_engines::shot_results::Data::U16(v) => u8::from((v >> bit) & 1 == 1), + pecos_engines::shot_results::Data::U32(v) => u8::from((v >> bit) & 1 == 1), + pecos_engines::shot_results::Data::U64(v) => u8::from((v >> bit) & 1 == 1), + other => panic!("unexpected register data type for {name}: {other:?}"), + } + }; + + experiment + .record_map + .iter() + .map(|&(reg, bit)| register_bit(reg, bit)) + .collect() +} + +/// XOR a relative-record definition over a shot's measurement bits. +fn xor_records(bits: &[u8], records: &[i32], num_measurements: usize) -> u8 { + records.iter().fold(0u8, |acc, &rec| { + let idx = i64::try_from(num_measurements).unwrap() + i64::from(rec); + let idx = usize::try_from(idx).expect("record index in range"); + acc ^ bits[idx] + }) +} + +/// Convert a `ShotVec` into per-shot detector syndromes and observable masks. +fn shots_to_syndromes( + results: &ShotVec, + experiment: &MemoryExperiment, +) -> (Vec>, Vec) { + let mut syndromes = Vec::with_capacity(results.shots.len()); + let mut masks = Vec::with_capacity(results.shots.len()); + for shot in &results.shots { + let bits = shot_record_bits(shot, experiment); + let syndrome: Vec = experiment + .detectors + .iter() + .map(|records| xor_records(&bits, records, experiment.num_measurements)) + .collect(); + let mask = u64::from(xor_records( + &bits, + &experiment.observable, + experiment.num_measurements, + )); + syndromes.push(syndrome); + masks.push(mask); + } + (syndromes, masks) +} + +/// Uniform circuit-level depolarizing noise for the engines/neo mapping. +fn depolarizing_noise(p: f64) -> pecos_engines::noise::DepolarizingNoiseModelBuilder { + pecos_engines::noise::DepolarizingNoiseModel::builder() + .with_prep_probability(p) + .with_meas_probability(p) + .with_p1_probability(p) + .with_p2_probability(p) +} + +/// Run the experiment on one stack and return its `ShotVec`. +fn run_stack( + experiment: &MemoryExperiment, + stack: SimStack, + p: f64, + shots: usize, + seed: u64, +) -> ShotVec { + sim(Qasm::from_string(&experiment.qasm)) + .stack(stack) + .noise(depolarizing_noise(p)) + .seed(seed) + .workers(4) + .shots(shots) + .run() + .expect("simulation run") +} + +/// Decode both stacks' samples with one MWPM decoder over the same DEM, +/// returning (engines errors, neo errors). +fn decode_logical_errors( + experiment: &MemoryExperiment, + p: f64, + engines: &ShotVec, + neo: &ShotVec, +) -> (u64, u64) { + let dem = DemBuilder::try_from_tick_circuit(&experiment.tick, p, p, p, p) + .expect("DEM from tick circuit") + .to_string_decomposed(); + let mut decoder = FusionBlossomDecoder::from_dem(&dem).expect("decoder from DEM"); + + let mut count = |results: &ShotVec| -> u64 { + let (syndromes, masks) = shots_to_syndromes(results, experiment); + let num_detectors = experiment.detectors.len(); + let flat: Vec = syndromes.concat(); + let predicted = decoder + .decode_batch_to_observables(&flat, masks.len(), num_detectors) + .expect("batch decode"); + predicted + .iter() + .zip(&masks) + .filter(|(pred, actual)| pred != actual) + .count() as u64 + }; + + (count(engines), count(neo)) +} + +#[test] +fn noiseless_surface_memory_is_silent_on_both_stacks() { + // Validates the generator end-to-end on each stack independently: the + // X-ancilla outcomes are individually random, so every detector and + // the logical observable XOR to zero only if the QASM, the record + // bookkeeping, and the register bit mapping all line up. + let experiment = build_surface_memory(3, 3); + + for stack in [SimStack::Engines, SimStack::Neo] { + let results = sim(Qasm::from_string(&experiment.qasm)) + .stack(stack) + .seed(11) + .shots(25) + .run() + .expect("noiseless run"); + let (syndromes, masks) = shots_to_syndromes(&results, &experiment); + for (shot_idx, syndrome) in syndromes.iter().enumerate() { + assert!( + syndrome.iter().all(|&bit| bit == 0), + "stack {stack:?} shot {shot_idx}: noiseless detectors must be silent, got {syndrome:?}" + ); + } + assert!( + masks.iter().all(|&m| m == 0), + "stack {stack:?}: noiseless logical observable must be trivial" + ); + } +} + +#[test] +fn surface_memory_ler_matches_across_stacks() { + // V5: d=3 and d=5 Z-memory under uniform depolarizing noise. Both + // stacks' LERs must have overlapping Jeffreys intervals, and each + // stack must show d=5 suppressing the LER below d=3. + // + // Calibration (20k shots, this circuit's sequential schedule): + // threshold sits near p = 0.004; at p = 0.003 the LERs are roughly + // 4.1e-3 (d=3) and 1.4e-3 (d=5) with ~3x suppression on both stacks. + // The sequential schedule's hook errors limit the suppression + // steepness; that affects both stacks identically. + // 20k shots: at 10k the per-stack error counts (~20-40) fluctuate too + // much for the suppression margin to be decisive. + let p = 0.003; + let shots = 20_000; + // High-confidence intervals so stack disagreement, not sampling + // noise, is what fails the equivalence check (~4.4 sigma per side). + // + // Sensitivity, stated honestly: at these counts the overlap criterion + // only fails for LER ratios beyond roughly 2.4x at d=3, so this test + // is a coarse end-to-end guard — its value is exercising a real QEC + // circuit with decoding through both stacks. Fine-grained discrepancy + // detection belongs to the V1 matrix's analytic-anchor cells (power + // ~1 against convention bugs like the 2/3 and 8/15 factors or the + // measurement state-flip/record-flip distinction). The seed-42 d=3 + // draw (engines 85 vs neo 60, ~2.1 sigma) was settled as sampling + // noise by an independent 6-seed 120k-shot-per-stack run (engines + // 517 vs neo 482, z = 1.11). + let equivalence_confidence = 0.99999; + // The suppression margin is smaller than the equivalence margin, so + // it gets its own (still strict) confidence. Pooling the two stacks + // for suppression is justified by that 120k-shot equivalence run, + // not by this test's own (weaker) overlap check. + let suppression_confidence = 0.99; + + let mut pooled_intervals = Vec::new(); + for distance in [3, 5] { + let experiment = build_surface_memory(distance, distance); + let engines = run_stack(&experiment, SimStack::Engines, p, shots, 42); + let neo = run_stack(&experiment, SimStack::Neo, p, shots, 42); + let (engines_errors, neo_errors) = decode_logical_errors(&experiment, p, &engines, &neo); + + let engines_ci = jeffreys_interval(engines_errors, shots as u64, equivalence_confidence); + let neo_ci = jeffreys_interval(neo_errors, shots as u64, equivalence_confidence); + println!( + "d={distance}: engines {engines_errors}/{shots} LER CI [{:.5}, {:.5}], \ + neo {neo_errors}/{shots} LER CI [{:.5}, {:.5}]", + engines_ci.0, engines_ci.1, neo_ci.0, neo_ci.1 + ); + + assert!( + engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, + "d={distance}: stack LERs are statistically incompatible: \ + engines {engines_errors}/{shots} vs neo {neo_errors}/{shots}" + ); + // With per-stack equivalence established, pool the stacks for the + // suppression physics check (doubles the statistics). + pooled_intervals.push(jeffreys_interval( + engines_errors + neo_errors, + 2 * shots as u64, + suppression_confidence, + )); + } + + // Error suppression: the pooled d=5 interval must sit strictly below + // the pooled d=3 interval (p = 0.003 is below threshold). + let d3 = pooled_intervals[0]; + let d5 = pooled_intervals[1]; + assert!( + d5.1 < d3.0, + "d=5 LER must be suppressed below d=3: \ + d5 CI [{:.5}, {:.5}] vs d3 CI [{:.5}, {:.5}]", + d5.0, + d5.1, + d3.0, + d3.1 + ); +} diff --git a/crates/pecos/tests/neo_v6_example_sweep_test.rs b/crates/pecos/tests/neo_v6_example_sweep_test.rs new file mode 100644 index 000000000..ba50b6f5f --- /dev/null +++ b/crates/pecos/tests/neo_v6_example_sweep_test.rs @@ -0,0 +1,211 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Validation-gate item V6: the neo-routable EXAMPLE sweep. +//! +//! The repository's `sim()`-facade examples (`crates/pecos/examples/*.rs`) +//! exercise a handful of distinct circuits. Most pin an explicit quantum +//! backend (`.quantum(...)`), which the neo stack deliberately rejects, but the +//! CIRCUITS themselves are neo-routable. This sweep runs each example-derived +//! circuit through `sim().stack(...)` on BOTH stacks (auto-backend) and checks +//! that they agree, covering circuit shapes the curated V1 matrix does not: +//! a 3-qubit entangling circuit, whole-register `measure q -> c` syntax, and a +//! purely deterministic program. +//! +//! Circuits that only run via `sim_neo` directly, or that need QIS/PHIR/custom +//! backends, are NOT part of this sweep (they are not facade-routable); the +//! curated cross-stack physics lives in `neo_equivalence_matrix_test.rs`. + +#![cfg(feature = "neo")] + +use std::collections::BTreeSet; + +use pecos::{SimStack, sim}; +use pecos_engines::shot_results::ShotVec; +use pecos_num::jeffreys_interval; +use pecos_programs::Qasm; + +const SHOTS: usize = 10_000; +/// ~4.4 sigma per side: a real stack disagreement, not sampling noise, fails. +const CONFIDENCE: f64 = 0.99999; +const SEED: u64 = 42; + +// --- Example-derived circuits (verbatim from crates/pecos/examples/*.rs) --- + +/// Bell pair with WHOLE-REGISTER measurement (`measure q -> c`), the form used +/// by every facade example. Noiseless support is exactly {00, 11}. +const BELL_WHOLE_REG: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + h q[0]; + cx q[0], q[1]; + measure q -> c; +"#; + +/// The 3-qubit circuit from `unified_sim_demo.rs`: q2 = q0 XOR q1, so every +/// noiseless shot has even parity (q0^q1^q2 = 0) over outcomes {000,011,101,110}. +const GHZ3: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + creg c[3]; + h q[0]; + h q[1]; + cx q[0], q[2]; + cx q[1], q[2]; + measure q -> c; +"#; + +/// Deterministic program (`unified_sim_demo.rs`): always reads "1". +const X_DETERMINISTIC: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + x q[0]; + measure q -> c; +"#; + +/// Single Hadamard with per-bit measurement (`sim_api_examples.rs`): {0, 1}. +const SINGLE_H: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + h q[0]; + measure q[0] -> c[0]; +"#; + +fn run(program: &str, stack: SimStack, seed: u64, noise: Option) -> ShotVec { + let builder = sim(Qasm::from_string(program)).stack(stack).seed(seed); + let results = match noise { + Some(p) => builder + .noise(pecos_engines::DepolarizingNoise { p }) + .shots(SHOTS) + .run(), + None => builder.shots(SHOTS).run(), + }; + results.expect("simulation run") +} + +fn bitstrings(v: &ShotVec) -> Vec { + v.shots + .iter() + .map(|s| s.data["c"].to_bitstring().expect("c register bits")) + .collect() +} + +fn support(v: &ShotVec) -> BTreeSet { + bitstrings(v).into_iter().collect() +} + +/// Fraction of shots whose `c` register satisfies `pred`, with its count. +#[allow(clippy::cast_precision_loss)] +fn rate_where(v: &ShotVec, pred: impl Fn(&str) -> bool) -> (u64, f64) { + let count = bitstrings(v).iter().filter(|b| pred(b)).count() as u64; + (count, count as f64 / SHOTS as f64) +} + +/// Assert the two stacks' rates for `pred` are statistically compatible +/// (independent seeds, Jeffreys overlap) and print the sweep row. +fn assert_cross_stack_rate( + name: &str, + program: &str, + noise: Option, + pred: impl Fn(&str) -> bool, +) { + // Independent seeds: agreement must come from matching conventions, not a + // shared RNG stream. + let engines = run(program, SimStack::Engines, SEED, noise); + let neo = run(program, SimStack::Neo, SEED ^ 0xA5A5, noise); + let (e_count, _) = rate_where(&engines, &pred); + let (n_count, _) = rate_where(&neo, &pred); + let e_ci = jeffreys_interval(e_count, SHOTS as u64, CONFIDENCE); + let n_ci = jeffreys_interval(n_count, SHOTS as u64, CONFIDENCE); + println!( + "V6 {name}: engines {e_count}/{SHOTS} CI [{:.4}, {:.4}], neo {n_count}/{SHOTS} CI [{:.4}, {:.4}]", + e_ci.0, e_ci.1, n_ci.0, n_ci.1 + ); + assert!( + e_ci.0 <= n_ci.1 && n_ci.0 <= e_ci.1, + "V6 {name}: stack rates are statistically incompatible: \ + engines {e_count}/{SHOTS} vs neo {n_count}/{SHOTS}" + ); +} + +#[test] +fn v6_deterministic_example_is_bit_identical_cross_stack() { + // A noiseless deterministic program must produce IDENTICAL ShotVecs on both + // stacks (same seed, no randomness) and read "1" every shot. + let engines = run(X_DETERMINISTIC, SimStack::Engines, SEED, None); + let neo = run(X_DETERMINISTIC, SimStack::Neo, SEED, None); + assert_eq!( + engines, neo, + "deterministic example must be bit-identical across stacks" + ); + assert_eq!(support(&neo), BTreeSet::from(["1".to_string()])); +} + +#[test] +fn v6_bell_whole_register_measure_matches() { + // Noiseless Bell with whole-register `measure q -> c`: both stacks must + // produce exactly the correlated support {00, 11} and a compatible P(00). + for stack in [SimStack::Engines, SimStack::Neo] { + let v = run(BELL_WHOLE_REG, stack, SEED, None); + assert_eq!( + support(&v), + BTreeSet::from(["00".to_string(), "11".to_string()]), + "Bell ({stack:?}) must only produce the correlated outcomes 00/11" + ); + } + assert_cross_stack_rate("bell_p00", BELL_WHOLE_REG, None, |b| b == "00"); +} + +#[test] +fn v6_three_qubit_example_preserves_parity() { + // The 3-qubit example sets q2 = q0 XOR q1, so every noiseless shot has even + // parity. Both stacks must honor that (covers a circuit shape and a + // whole-register measurement wider than the V1 matrix). + let even_parity = |b: &str| b.bytes().filter(|&c| c == b'1').count() % 2 == 0; + for stack in [SimStack::Engines, SimStack::Neo] { + let v = run(GHZ3, stack, SEED, None); + assert!( + bitstrings(&v).iter().all(|b| even_parity(b)), + "3-qubit example ({stack:?}) must yield only even-parity outcomes" + ); + assert!( + support(&v).len() >= 3, + "3-qubit example ({stack:?}) should explore its 4-outcome support, got {:?}", + support(&v) + ); + } + // The even-parity fraction is 1.0 noiselessly on both stacks; under + // depolarizing noise it drops below 1 on BOTH stacks at a compatible rate. + assert_cross_stack_rate("ghz3_even_parity_noisy", GHZ3, Some(0.02), even_parity); +} + +#[test] +fn v6_single_hadamard_example_matches() { + // Single Hadamard, per-bit measurement: support {0, 1} on both stacks and a + // compatible P(0) ~ 0.5. + for stack in [SimStack::Engines, SimStack::Neo] { + let v = run(SINGLE_H, stack, SEED, None); + assert_eq!( + support(&v), + BTreeSet::from(["0".to_string(), "1".to_string()]), + "single-H ({stack:?}) must produce both 0 and 1" + ); + } + assert_cross_stack_rate("single_h_p0", SINGLE_H, None, |b| b == "0"); +} diff --git a/crates/pecos/tests/test_data/hugr/ch_gate.hugr b/crates/pecos/tests/test_data/hugr/ch_gate.hugr new file mode 100644 index 000000000..7b404de25 Binary files /dev/null and b/crates/pecos/tests/test_data/hugr/ch_gate.hugr differ diff --git a/crates/pecos/tests/test_data/hugr/rx_pi_tuple_const.hugr b/crates/pecos/tests/test_data/hugr/rx_pi_tuple_const.hugr new file mode 100644 index 000000000..7d15dcf89 Binary files /dev/null and b/crates/pecos/tests/test_data/hugr/rx_pi_tuple_const.hugr differ diff --git a/crates/pecos/tests/test_data/hugr/ry_angle_tuple.hugr b/crates/pecos/tests/test_data/hugr/ry_angle_tuple.hugr new file mode 100644 index 000000000..41f41934c Binary files /dev/null and b/crates/pecos/tests/test_data/hugr/ry_angle_tuple.hugr differ diff --git a/crates/pecos/tests/unified_sim_api_test.rs b/crates/pecos/tests/unified_sim_api_test.rs index a27699e72..cf4f6e862 100644 --- a/crates/pecos/tests/unified_sim_api_test.rs +++ b/crates/pecos/tests/unified_sim_api_test.rs @@ -102,14 +102,16 @@ mod tests { let _results2 = sim(Qasm::from_string("OPENQASM 2.0; qreg q[1];")) .seed(42) .quantum(sparse_stab()) - .run(100); + .shots(100) + .run(); // Pattern 3: Override auto-selection with explicit .classical() let _results3 = sim(Qis::from_string("define void @main() { ret void }")) .classical( qis_engine().program(Qis::from_string("define void @main() { ret void }")), ) - .run(100); + .shots(100) + .run(); // Pattern 4: Various configuration options work with new API let _results4 = sim(Qasm::from_string("OPENQASM 2.0; qreg q[2];")) @@ -119,7 +121,8 @@ mod tests { .verbose(true) .qubits(2) .quantum(state_vector()) - .run(1000); + .shots(1000) + .run(); }; } @@ -134,18 +137,21 @@ mod tests { // QASM -> QASM engine let _qasm_results = sim(Qasm::from_string("OPENQASM 2.0; qreg q[1];")) .quantum(state_vector()) - .run(10); + .shots(10) + .run(); // LLVM -> LLVM engine let _llvm_results = sim(Qis::from_string("define void @main() { ret void }")) .quantum(state_vector()) - .run(10); + .shots(10) + .run(); // HUGR -> Selene engine let _hugr_results = sim(Hugr::from_bytes(vec![0x00, 0x01, 0x02])) .quantum(state_vector()) .qubits(1) - .run(10); + .shots(10) + .run(); }; } } diff --git a/docs/README.md b/docs/README.md index 9eab704d6..8f78eb46b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,7 +84,7 @@ Simulate a distance-3 repetition code with syndrome extraction using [Guppy](htt "#); // Run 10 shots - let results = sim(circuit).seed(42).run(10)?; + let results = sim(circuit).seed(42).shots(10).run()?; println!("{:?}", results); // 0 = both |0⟩, 3 = both |1⟩ (always correlated!) Ok(()) @@ -101,7 +101,7 @@ For OpenQASM, PHIR, or other formats, see the [User Guide](user-guide/getting-st - **Fast Simulation**: Leverages a fast stabilizer simulation algorithm. - **Multi-language extensions**: Core functionalities implemented via Rust for performance and safety. Additional add-ons and extension support in C/C++ via Cython. -- **QIR Support**: Execute Quantum Intermediate Representation programs (requires LLVM version 14). +- **QIR Support**: Execute Quantum Intermediate Representation programs (requires LLVM version 21.1). ## Available Implementations diff --git a/docs/development/DEVELOPMENT.md b/docs/development/DEVELOPMENT.md index 96690a8fb..ea99ae1b7 100644 --- a/docs/development/DEVELOPMENT.md +++ b/docs/development/DEVELOPMENT.md @@ -69,9 +69,9 @@ For developers who want to contribute or modify PECOS: (e.g. `uv sync --group examples --group cuda13`). Pick one CUDA major -- `cuda12` and `cuda13` are mutually exclusive. -6. **LLVM 14 Setup (Required for LLVM IR/QIS Support)** +6. **LLVM 21.1 Setup (Required for LLVM IR/QIS Support)** - PECOS requires LLVM version 14 for LLVM IR execution features. + PECOS requires LLVM version 21.1 for LLVM IR execution features. **Quick setup:** ```sh @@ -79,7 +79,9 @@ For developers who want to contribute or modify PECOS: cargo build ``` - For detailed installation instructions for all platforms (macOS, Linux, Windows), see the [**LLVM Setup Guide**](../user-guide/llvm-setup.md). + `pecos install llvm` is the managed shared-LLVM path on supported + Debian/Ubuntu-compatible Linux systems. For macOS, Windows, and other Linux + distributions, see the [**LLVM Setup Guide**](../user-guide/llvm-setup.md). 7. You may wish to explicitly activate the environment for development. To do so: @@ -178,7 +180,7 @@ PECOS uses `~/.pecos/` to store external dependencies and build artifacts that c ``` ~/.pecos/ -├── llvm/ # LLVM-14 installation (for QIR/LLVM IR execution) +├── deps/llvm-21.1/ # LLVM 21.1 installation (for QIR/LLVM IR execution) ├── deps/ # Downloaded C++ dependencies (Stim, etc.) └── cache/ # Build artifacts and intermediate files ``` diff --git a/docs/development/dev-tools.md b/docs/development/dev-tools.md index 774dacfae..e6747b24b 100644 --- a/docs/development/dev-tools.md +++ b/docs/development/dev-tools.md @@ -24,7 +24,7 @@ pecos python build --profile release # Release build pecos python build --profile native # Release + native-CPU codegen (Rust and C++) # Dependency installation -pecos install llvm # Install LLVM 14 to ~/.pecos/deps/llvm-14/ +pecos install llvm # Install managed LLVM 21.1 where supported pecos install cuda # Install CUDA Toolkit to ~/.pecos/deps/cuda/ pecos install cuquantum # Install cuQuantum SDK to ~/.pecos/deps/cuquantum/ pecos install --all # Install all optional dependencies @@ -33,7 +33,8 @@ pecos upgrade llvm # Upgrade (force reinstall) LLVM # Inspection pecos llvm check # Check LLVM installation status -pecos llvm configure # Configure .cargo/config.toml +pecos llvm configure # Configure .cargo/config.toml using detected LLVM +pecos llvm configure /path/to/llvm # Configure an explicit user-managed LLVM pecos cuda check # Check CUDA availability pecos sys-info # Show toolchain and environment info @@ -100,9 +101,12 @@ Run `just --list` to see all available commands. ### Install LLVM ```bash -# Automated installation (downloads pre-built binaries) +# Automated installation where PECOS can provide shared LLVM pecos install llvm +# Accept the managed-install prompt +pecos install llvm --yes + # Force reinstall pecos install llvm --force @@ -110,7 +114,21 @@ pecos install llvm --force pecos install llvm --no-configure ``` -This downloads and installs LLVM 14 to `~/.pecos/deps/llvm-14/`. +On Debian/Ubuntu-compatible Linux systems this downloads apt.llvm.org shared +LLVM packages into `~/.pecos/deps/llvm-21.1/` without `sudo`. The managed +install is the preferred developer path where it is available, but it is a +large toolchain install. `pecos install llvm` prints what it is about to install +and asks for confirmation before downloading. + +On macOS, install Homebrew LLVM 21 (`brew install llvm@21`) and run +`pecos llvm configure`. On native Windows MSVC, use +`scripts\ci\install-llvm-21-windows.ps1` to install the conda-forge LLVM 21.1 +toolchain, then configure `~\.pecos\deps\llvm-21.1\Library`. + +`pecos rust test` requires shared LLVM for the workspace HUGR test lane. LLVM +21.1 static test links can use multiple GB of RAM each, so PECOS fails early +instead of letting `just dev` spawn enough concurrent linkers to overwhelm a +normal development machine. ### Check LLVM Status @@ -125,9 +143,15 @@ pecos llvm check --quiet ```bash pecos llvm configure + +# Or explicitly use a system/Homebrew/apt LLVM instead of the managed install +pecos llvm configure /path/to/llvm ``` -Updates `.cargo/config.toml` with the correct `LLVM_SYS_140_PREFIX` environment variable. +Updates `.cargo/config.toml` with the correct `LLVM_SYS_211_PREFIX` environment variable. +Explicit paths are canonicalized, so configuring a symlink records the resolved +LLVM directory. Re-run `pecos llvm configure /path/to/llvm` after repointing the +symlink. ### Find LLVM Path @@ -188,7 +212,7 @@ Use `just check-all` before a broad PR; it runs the build, tests, lint gate, and | Variable | Description | Default | |----------|-------------|---------| | `PECOS_HOME` | PECOS cache and data directory | `~/.pecos` | -| `LLVM_SYS_140_PREFIX` | LLVM 14 installation path | auto-detected | +| `LLVM_SYS_211_PREFIX` | LLVM 21.1 installation path | auto-detected | | `RUST_LOG` | Log level for build output (`info` shows download progress) | `warn` | ## Typical Workflows @@ -199,13 +223,17 @@ Use `just check-all` before a broad PR; it runs the build, tests, lint gate, and # 1. Check if LLVM is already available pecos llvm check -# 2. If not, install it +# 2. If not, install it where managed shared LLVM is supported pecos install llvm # 3. Now you can build with LLVM support cargo build -p pecos --features llvm ``` +On macOS use `brew install llvm@21 && pecos llvm configure`. On native Windows +MSVC, use `scripts\ci\install-llvm-21-windows.ps1` and configure +`~\.pecos\deps\llvm-21.1\Library`. + Or using Justfile: ```bash just install-llvm diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md new file mode 100644 index 000000000..16c6d8c05 --- /dev/null +++ b/docs/development/from-guppy-dem-handoff.md @@ -0,0 +1,125 @@ +# `DetectorErrorModel.from_guppy` Handoff + +This note is for future work on the DEM polish path. It captures the current +local fix and the validation target for constrained-ancilla surface-code DEMs. + +## Context + +The `dem-polish` work adds a Python-level `DetectorErrorModel.from_guppy(...)` +entry point. The intended shape is: + +1. Build any Guppy program, including constrained surface-code memory circuits. +2. Trace it through Selene/QIS into a `TickCircuit`. +3. Attach caller-provided detector and observable metadata. +4. Build the native PECOS DEM from that traced circuit. + +This should support calls like: + +```python,notest +from pecos.guppy import get_num_qubits, make_surface_code +from pecos.qec import DetectorErrorModel + +program = make_surface_code( + distance=9, + num_rounds=18, + basis="Z", + ancilla_budget=17, +) + +dem = DetectorErrorModel.from_guppy( + program, + num_qubits=get_num_qubits(9, ancilla_budget=17), + detectors_json=detectors_json, + observables_json=observables_json, + num_measurements=num_measurements, + p1=p, + p2=p, + p_meas=p, + p_prep=p, +) +``` + +## Import-Time Issue + +`pecos_rslib.qec.DetectorErrorModel` is not currently subclassable from Python. +Defining: + +```python,notest +class DetectorErrorModel(_RustDetectorErrorModel): + ... +``` + +causes `import pecos` to fail with: + +```text +TypeError: type 'pecos_rslib.qec.DetectorErrorModel' is not an acceptable base type +``` + +The current local fix is to re-export the Rust class directly and attach the +Python convenience constructor: + +```python,notest +DetectorErrorModel = _RustDetectorErrorModel +DetectorErrorModel.from_guppy = classmethod(...) +``` + +This keeps the public API as `pecos.qec.DetectorErrorModel.from_guppy(...)` +while preserving the Rust class identity for objects returned by +`from_circuit(...)` and `from_guppy(...)`. + +## Constrained-Ancilla Surface DEM Target + +The key surface-code use case is Helios-sized rotated surface code memory: + +- `distance=9` +- `ancilla_budget=17` +- `num_qubits=98` +- both X and Z memory bases +- DEMs built from the traced Guppy/Selene/QIS path + +Important checks: + +```bash +uv run python -c "from pecos.guppy import make_surface_code, get_num_qubits; from pecos.qec import DetectorErrorModel; print(get_num_qubits(9, ancilla_budget=17)); print(hasattr(DetectorErrorModel, 'from_guppy')); make_surface_code(distance=9, num_rounds=18, basis='Z', ancilla_budget=17); print('ok')" +``` + +Expected output includes: + +```text +98 +True +ok +``` + +In the downstream `surface-memory-helios` repo, this smoke test currently +generates a constrained d=9 traced DEM: + +```bash +uv run python -c "from surface_memory_helios import surface_memory_dem; dem = surface_memory_dem(distance=9, rounds=18, basis='Z', p=0.01, decoder='pymatching', dem_source='traced_qis', ancilla_budget=17); print(len(dem.splitlines())); print(dem.splitlines()[0])" +``` + +The latest local run produced 37,066 DEM lines and a detector metadata first +line. + +## Follow-Up Guidance + +- Prefer the generic `from_guppy(...)` abstraction for future DEM construction + rather than adding more surface-specific tracing plumbing. +- Runtime plugins are intentionally generic: pass any Selene-compatible runtime + plugin object through `pecos.selene_engine(runtime)` or the higher-level + `runtime=...` arguments on traced Guppy/DEM helpers. PECOS should depend only + on the public shape of those plugin objects; experiment-specific runtimes and + package sources belong in downstream projects. +- Runtime-produced `Idle` gates are preserved in the QIS operation trace and + replayed into QEC circuits as `TimeUnits` with the convention + `1 TimeUnit = 1 ns`. They only affect DEMs when an idle-noise parameter such + as `p_idle`, `t1/t2`, `p_idle_linear_rate`, or `p_idle_quadratic_rate` is set. +- Keep the surface helper path compatible with constrained ancilla budgets: + pass `ancilla_budget` into both `make_surface_code(...)` and + `get_num_qubits(...)` when tracing surface Guppy. +- Avoid reintroducing any `circuit_source="traced_qis"` rejection for + `ancilla_budget`; constrained Guppy programs are valid and traceable. +- Ensure PyMatching users can get decomposed DEM text from the `from_guppy(...)` + result, e.g. via `to_string_decomposed()`. +- Keep or add regression coverage for constrained d=9, `ancilla_budget=17` + through the Guppy/from_guppy route. diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md new file mode 100644 index 000000000..7acf0f0d5 --- /dev/null +++ b/docs/development/hosted-operations.md @@ -0,0 +1,201 @@ +# Hosted Operations + +This note sketches a generic PECOS abstraction for operations that must stay +attached to a later quantum host operation during lowering, scheduling, tracing, +and DEM construction. + +## Motivation + +Some source circuits contain local operations whose physical meaning is only +complete when paired with a later host operation. A common example is a +single-qubit Clifford basis change that prepares a data qubit for a two-qubit +interaction. If a compiler or runtime legally moves that local pulse far away +from the host, the ideal unitary can remain correct while the physical idle +noise model changes substantially. + +Plain source adjacency and public language-level barriers are not enough as a +long-term contract: + +- Source order can be changed by Guppy/HUGR/QIR/runtime lowering. +- Public barriers may be optimized away before QIS operation collection. +- A barrier says "do not reorder across this point"; it does not say "this + local pulse is hosted by that two-qubit gate". + +PECOS needs a way to represent this source intent directly and fail loudly when +the intent is dropped or cannot be honored. + +## Definition + +A hosted operation relationship binds one or more local source operations to one +host source operation: + +- `host_id`: stable source identifier for the host operation. +- `host_kind`: generic host category, for example `two_qubit_gate` or + `measurement`. +- `local_role`: local operation role, for example `basis_prefix`, + `basis_suffix`, `frame_update`, or `readout_prefix`. +- `local_qubits`: source qubits touched by the local operation. +- `host_qubits`: source qubits touched by the host operation. +- `policy`: requested lowering/scheduling policy. + +The relationship is not a physical-device-specific instruction. It is source +intent that runtimes may use to lower better schedules, and that PECOS can use +to validate traces and build diagnostics. + +## Initial Policies + +Start with strict, observable policies instead of implicit best effort: + +- `metadata_only`: preserve provenance, but do not require adjacency. +- `same_runtime_batch`: the local operation and host must be submitted to the + runtime in the same hosted group or batch. +- `max_idle_time`: the lowered trace must show no more than a configured time + between the local operation and host on the local qubit. +- `lowering_required`: the host must produce a compatible lowered operation. + +For any policy stronger than `metadata_only`, failure should be explicit and +actionable. Silent fallback to unhosted behavior is not acceptable. + +## Trace Requirements + +Traces should preserve both the local and host sides: + +- local lowered operations carry `source_kind`, `source_label`, `host_id`, and + `local_role`. +- host lowered operations carry `source_kind`, `source_label`, and `host_id`. +- replay code can pair local operations to hosts by exact `host_id`, not by + nearest-neighbor inference. +- audit tools can report whether provenance is exact, inferred, mismatched, or + missing. + +Exact provenance is necessary but not sufficient. It proves PECOS can identify +the intended host; it does not prove the lowered schedule kept the operations +adjacent or within a noise-model threshold. + +## Candidate Implementation Layers + +### 1. Metadata-Only Vertical Slice + +The smallest useful slice is the current trace-metadata approach: + +1. Emit qubit-scoped metadata before each local operation and host operation. +2. Preserve metadata through QIS operation collection and runtime replay. +3. Attach metadata to lowered operations. +4. Require exact host matching in downstream audits. + +This slice is useful for diagnostics and DEM cache identity, but it does not +constrain scheduling. + +### 2. Barrier-Preserving Lowering + +Preserving public barriers into `Operation::Barrier` can create runtime replay +batch boundaries, and PECOS replay should drain at those boundaries. This is a +valid generic improvement, but it still does not express "this local pulse is +hosted by that operation". It should not be the only hosted-operation plan. + +Current state: + +- SLR QIR codegen can emit QIR barrier calls such as + `__quantum__qis__barrierN__body`. +- `pecos-qis-ffi-types` and `pecos-qis-ffi` already have an + `Operation::Barrier` control-flow marker. +- PECOS traced Selene replay drains runtime operations at `Operation::Barrier` + when that marker is present. +- A minimal Guppy public `barrier(...)` probe is currently optimized away before + PECOS QIS operation collection. The captured raw operation trace contains + allocations, gates, measurements, and releases, but no `Barrier` operation. +- PECOS-owned runtime-barrier helpers such as + `pecos_qis_runtime_barrier_qubit_hugr` return their qubit arguments to create + Guppy/HUGR data dependencies and queue a real `Operation::Barrier`, so Selene + runtime lowering drains the current batch before later dependent operations. +- SZZ/SZZdg data-prefix barriers use the two-qubit + `pecos_qis_runtime_barrier_qubits2_hugr` helper. The helper consumes and + returns the host ancilla and data qubit, and the generated source places it + before the hosted data-prefix pulse. This is the dependency needed to prevent + the data-prefix pulse from being scheduled long before the host qubit is + ready. + +So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public +barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` +instead of dropping them as pass-through no-ops. This is useful, but still +secondary to a hosted-operation relationship because a barrier does not identify +which host operation a local pulse belongs to. + +### 3. Explicit Hosted Operation + +The stronger long-term abstraction is a QIS-level hosted operation or hosted +group: + +```text +HostedGroup { + host_id, + locals: [QuantumOp], + host: QuantumOp, + policy, +} +``` + +An equivalent representation could be a pair of begin/end host markers around a +set of normal `QuantumOp`s, if that is easier to thread through existing +collectors. The key requirement is that the runtime receives a relationship, +not just a sequence of independent gates. + +## Surface-Code SZZ Example + +For SZZ/SZZdg surface-code checks: + +1. The source renderer forward-flows data-frame Cliffords. +2. Before an SZZ/SZZdg interaction, it emits any required non-virtual local + data prefix. +3. The prefix is tagged as `local_role=basis_prefix` with the SZZ/SZZdg + `host_id`. +4. The SZZ/SZZdg interaction is tagged as the host with the same `host_id`. +5. Runtime trace replay verifies exact provenance and, when requested, a + bounded prefix-host idle threshold. + +This should remain generic. PECOS should not encode downstream device names or +experiment packages into the abstraction. + +## Fail-Loud Conditions + +PECOS should fail loudly when: + +- hosted metadata is attached to a source qubit but never consumed by a + compatible source operation. +- two hosted metadata maps disagree on a key for the same source operation. +- a required host is optimized away or cannot be matched to a lowered operation. +- exact host provenance is requested but only inferred matching is available. +- a scheduling policy such as `max_idle_time` is exceeded in the lowered trace. + +Error messages should include source labels, qubits, host labels, observed idle +duration or tick separation, and the relevant policy. + +## Open Questions + +- Should hosted groups be a first-class QIS `Operation`, or represented as + markers plus normal `QuantumOp`s? +- Which Guppy/HUGR constructs can carry hosted relationships without being + optimized away? +- Do runtime plugins need an explicit hosted-operation callback, or can PECOS + lower hosted groups into existing runtime APIs with flush boundaries and + metadata? +- How should hosted operations interact with DEM generation for decoders that + consume raw hypergraph DEMs versus decomposed DEMs? +- Can the same abstraction cover measurement-hosted readout prefixes and + two-qubit-gate-hosted basis prefixes? + +## Recommended Next Slice + +1. Add a minimal barrier-survival diagnostic for public Guppy barriers in QIS + traces. Current strict-xfail target: + `test_guppy_barrier_survives_into_qis_operation_trace`. The generated SZZ + path has a positive regression, + `test_szz_runtime_barrier_survives_into_qis_operation_trace`, through the + PECOS runtime-barrier helper. +2. If preserving public barriers is small and generic, implement it as a + separate quality-of-lowering improvement. +3. Prototype an explicit hosted SZZ prefix relationship in the QIS trace path. +4. Use idle audits to compare hosted and unhosted lowering on representative + surface-code memory circuits. +5. Keep downstream experiments guarded by exact provenance and prefix-host idle + thresholds until hosted scheduling is proven by traces. diff --git a/docs/development/measurement-crosstalk-dem.md b/docs/development/measurement-crosstalk-dem.md new file mode 100644 index 000000000..fc50d5c3b --- /dev/null +++ b/docs/development/measurement-crosstalk-dem.md @@ -0,0 +1,87 @@ +# Measurement Crosstalk DEM Semantics + +This note records the intended long-term semantics for adding measurement-crosstalk +sources to PECOS detector error model generation. The goal is to keep simulator +noise, traced-QIS circuits, raw hypergraph DEMs, and decomposed decoder inputs +consistent without silently substituting an unrelated scalar noise model. + +## Runtime Source Model + +The general noise model represents measurement crosstalk with payload gates: + +- `MeasCrosstalkLocalPayload` identifies local victim qubits. +- `MeasCrosstalkGlobalPayload` identifies excluded active measurement qubits; the + victims are the live prepared qubits not listed in the payload. + +For each candidate victim, the simulator independently samples a crosstalk event +with the local or global payload probability. When an event occurs, the simulator +performs a hidden `MZ` on the victim and samples a transition from the configured +model: + +- `0->0` and `1->1` leave the victim unchanged. +- `0->1` and `1->0` apply `X`. +- `0->L` and `1->L` leak the victim; with `leak2depolar`, leakage is replaced by + an explicit depolarized Pauli/no-op branch in the simulator. + +The transition is conditioned on the hidden measurement outcome, so the exact +channel is not always a fixed Pauli channel independent of circuit state. + +## DEM Requirements + +A crosstalk DEM implementation should satisfy these constraints: + +- Preserve crosstalk as a first-class source family in source metadata. +- Use the actual payload placement from the traced/lowered circuit. +- Derive global-payload victims from the live prepared qubit set at that point in + the circuit, not from static qubit count alone. +- For exact mode, replay each hidden-measurement/transition branch against the + same detector and observable metadata used by the ideal circuit. +- Fail loudly if a crosstalk branch changes measurement dependencies in a way that + cannot be represented as a detector/observable flip against the ideal record. +- Keep any Pauli-twirled or averaged treatment explicit and opt-in; it must not be + used under a name that implies exact crosstalk DEM support. + +## Implemented Mode + +`NoiseConfig` now exposes `MeasurementCrosstalkDemMode::ExactDeterministic` +for the local-payload subset. In this mode the circuit-aware DEM builder: + +- Replays the ideal Clifford circuit up to each `MeasCrosstalkLocalPayload`. +- Synthesizes the hidden `MZ` on the payload victim. +- Requires that hidden result to be deterministic and state-independent. +- Emits an `X`-equivalent DEM source with `DirectSourceFamily::MeasurementCrosstalk` + for `0->1` or `1->0` transitions. +- Emits no contribution for implicit `0->0` or `1->1` transitions. +- Fails loudly if global payloads, leakage transitions, missing circuit context, + unsupported pre-payload gates, or nondeterministic hidden outcomes are present. + +This mode is intentionally narrow: it is exact for the deterministic local cases +it accepts, and it rejects cases that still need a branch-level representation. + +## Implementation Plan + +1. Extend the exact crosstalk DEM path beyond deterministic local bit-flip + transitions. +2. Add global-payload victim selection from the live prepared qubit set. +3. Reuse the exact branch replay machinery where possible: compute the ideal + measurement parity expressions once, then evaluate branch effects by replaying + hidden `MZ` plus the transition action at each payload victim. +4. Add `leak2depolar` transition expansion into explicit Pauli/no-op branches. +5. Emit raw hypergraph DEM contributions with crosstalk source metadata. +6. Extend source-level decomposition so graph-like decoder inputs preserve the + same crosstalk source identity and fail loudly on irreducible branch effects. +7. Thread the new options through Python bindings and surface helper APIs. +8. Keep coverage diagnostics reporting unsupported crosstalk branches as omitted + until the relevant DEM modes are explicitly enabled and tested. + +## Minimal Tests + +The first implementation tests should cover: + +- Local payload with a single deterministic victim where `0->1` produces the same + detector flip as an `X` at that spacetime point. +- Local payload with `0->0`/`1->1` only, producing no DEM contribution. +- Global payload victim selection excluding the active measured qubits. +- `leak2depolar` transition expansion into explicit Pauli/no-op branches. +- Fail-loud behavior when hidden measurement changes detector dependencies rather + than only flipping deterministic parities. diff --git a/docs/development/noise-event-replay.md b/docs/development/noise-event-replay.md new file mode 100644 index 000000000..02ec873c5 --- /dev/null +++ b/docs/development/noise-event-replay.md @@ -0,0 +1,145 @@ +# Noise Event Replay and Failure Diagnostics + +This note records a future debugging path for understanding logical failures in +noisy circuit simulations. The goal is to make sampled noise events +reproducible and inspectable without turning normal simulation runs into large +trace dumps. + +## Motivation + +Detector error models tell us how physical error mechanisms can affect +detectors and observables, but they do not show which stochastic noise events +actually occurred in a sampled shot. When a decoder reports a logical failure, +we often want to inspect the concrete sampled events that produced that failure: + +- which noise source sampled an event, +- which ideal gate or payload location it was attached to, +- which branch was selected, +- which qubits and measurement results were involved, +- and whether the resulting syndrome pattern looked decoder-ambiguous. + +This is especially useful for local emulator studies where PECOS controls the +noise sampling. For hardware data, the physical events are not directly known, +but the same machinery can still be used to compare hardware syndromes against +failure patterns from calibrated local simulations. + +## Decoder-Dependent Failure Selection + +"Failed shots only" is a useful logging mode, but it is not intrinsic to the +simulator. Whether a shot is a logical failure depends on the analysis layer: + +- the detector and observable metadata, +- the DEM used for decoding, +- the decoder backend, +- decoder options such as graph-like decomposition or correlated matching, and +- the logical-failure convention used by the experiment. + +For that reason, the simulator should not decide by itself which shots are +interesting. Instead, the preferred long-term flow is: + +1. Run or replay shots with deterministic per-shot seeds. +2. Decode the resulting detection events in the analysis layer. +3. Select failed or otherwise interesting shot ids. +4. Replay only those shot ids with noise-event tracing enabled. + +This keeps logging sparse while preserving a clean separation between simulation +and decoder-specific analysis. + +## Per-Shot Reproducibility + +The key primitive is deterministic replay from stable seeds. A run should be +able to derive all randomness from a root seed and a shot identity: + +```text +run_seed + -> shot_seed(run_seed, shot_index) + -> component_seed(shot_seed, "noise") + -> component_seed(shot_seed, "runtime") + -> component_seed(shot_seed, "decoder" or analysis-only randomness) +``` + +The exact derivation should be explicit, versioned, and independent of worker +count. Replaying `(program, noise_config, runtime_config, run_seed, shot_index)` +should reproduce the same sampled noise events even if the original run used a +different number of workers. + +This is more important than logging everything during the first pass. Sparse +noise logs may be small at low physical error rates, but deterministic replay +lets us defer expensive diagnostics until we know which shots matter. + +## Optional Event Trace Schema + +When tracing is enabled, each sampled non-identity noise event should carry +enough source information to connect it back to the ideal program and DEM source +metadata. A compact JSONL-style record could contain: + +```json +{ + "shot": 17, + "shot_seed": "0x...", + "event_index": 42, + "tick": 19, + "gate_index": 3, + "gate_type": "SZZ", + "gate_qubits": [4, 12], + "source_family": "TwoQubitGate", + "noise_parameter": "p2", + "probability": 0.001, + "branch": "IX", + "random_draw": 0.00042 +} +``` + +Some source families need additional payload fields: + +- idle events should record duration and the axis-specific rate terms, +- replacement branches should record whether the ideal gate was omitted, +- measurement crosstalk should record payload gate type, candidate victim, and + transition label, +- measurement errors should record the measurement result id when available. + +Normal runs should default to no trace. Useful opt-in modes include: + +- trace every sampled event for small debugging runs, +- trace the first `N` shots, +- replay and trace a caller-provided list of shot ids, +- summarize event counts by source family without writing per-event records. + +## Data Volume + +Full logs may be acceptable for small studies because physical error events are +sparse. They can still become large quickly when the number of gates, shots, +distances, or parameter scans increases. The implementation should therefore +avoid coupling ordinary simulation output to full event logs. + +Recommended defaults: + +- store shot seeds or enough metadata to reconstruct them, +- store detection events and logical outcomes as usual, +- write detailed event records only in explicit diagnostic/replay modes, +- support streaming event records so failed-shot replays do not require keeping + all events in memory. + +## Implementation Sketch + +1. Add a worker-independent per-shot seed derivation API. +2. Ensure each simulator/noise layer receives deterministic component RNGs + derived from the shot seed. +3. Add an optional `NoiseEventSink` trait or equivalent callback in Rust. +4. Emit event records only for sampled non-identity branches, with source + metadata attached at the noise-model location. +5. Expose a replay API that accepts explicit shot ids or shot seeds. +6. Add Python helpers that decode first, select failed shots, then replay those + shots with tracing enabled. +7. Add compact summaries that aggregate event counts by source family, gate + type, branch, and qubit region. + +## Open Questions + +- How should runtime-generated events be traced when a runtime has its own RNGs? +- Should event traces include raw random draws, or only branch outcomes plus the + seed needed to reproduce them? +- Which event identifiers should be stable across circuit recompilation, and + which should be explicitly tied to the lowered/traced circuit version? +- How should traces link back to DEM contribution/source records when one sampled + event corresponds to multiple detector/observable effects? diff --git a/docs/user-guide/cli.md b/docs/user-guide/cli.md index 660b064ce..2d3fdfee7 100644 --- a/docs/user-guide/cli.md +++ b/docs/user-guide/cli.md @@ -126,9 +126,9 @@ For quick checks without compilation, use `just doctor`: ```bash $ just doctor -LLVM 14: - [OK] installed: 14.0.6 at /home/user/.pecos/deps/llvm - [OK] .cargo/config.toml: LLVM_SYS_140_PREFIX configured +LLVM 21.1: + [OK] installed: 21.1.8 at /home/user/.pecos/deps/llvm-21.1 + [OK] .cargo/config.toml: LLVM_SYS_211_PREFIX configured Python: [OK] uv: uv 0.11.3 diff --git a/docs/user-guide/cmake-setup.md b/docs/user-guide/cmake-setup.md index bbd1c5061..443e0dcd6 100644 --- a/docs/user-guide/cmake-setup.md +++ b/docs/user-guide/cmake-setup.md @@ -94,10 +94,10 @@ Optional decoders: `pecos python build` will detect cmake automatically and pass `--features mwpf` to maturin. To check the decoder from Python: ```python -from pecos_rslib.qec import ObservableSubgraphDecoder # MWPF-capable decoder +from pecos_rslib.qec import LogicalSubgraphDecoder # MWPF-capable decoder # Construct with a real DEM + stabilizer coords: -# decoder = ObservableSubgraphDecoder(dem_str, stab_coords, inner_decoder="mwpf") +# decoder = LogicalSubgraphDecoder(dem_str, stab_coords, inner_decoder="mwpf") ``` Set `PECOS_BUILD_MWPF=0` to force MWPF off even when cmake is present (useful for reproducing the lean build locally). `PECOS_BUILD_MWPF=1` forces it on, which is what CI sets. diff --git a/docs/user-guide/llvm-setup.md b/docs/user-guide/llvm-setup.md index 3a523ea88..fccc1f4a1 100644 --- a/docs/user-guide/llvm-setup.md +++ b/docs/user-guide/llvm-setup.md @@ -21,12 +21,12 @@ If you don't need QIS LLVM IR/QIR execution features, you can skip LLVM installa ## Installation Options -### Option 1: Automatic Installation (Recommended) +### Option 1: PECOS-Managed Installation (Recommended Where Available) -Use the `pecos` CLI (`pecos install llvm`, or `cargo run -p pecos-cli -- install llvm` in a source checkout) to automatically download and install LLVM 14.0.6: +Use the `pecos` CLI (`pecos install llvm`, or `cargo run -p pecos-cli -- install llvm` in a source checkout) to automatically download and install LLVM 21.1.8 where PECOS can provide a verified shared LLVM package: ```bash -# Install LLVM 14.0.6 to ~/.pecos/deps/llvm-14/ (~400MB, ~5 minutes) +# Install LLVM 21.1.8 to ~/.pecos/deps/llvm-21.1/ cargo run -p pecos-cli -- install llvm # Build PECOS with LLVM support @@ -35,19 +35,30 @@ cargo build --features llvm The `install` command automatically: -- Downloads the correct LLVM binary for your platform -- Extracts it to `~/.pecos/deps/llvm-14/` +- Downloads a shared LLVM toolchain on supported platforms +- Extracts it to `~/.pecos/deps/llvm-21.1/` - Configures PECOS by updating `.cargo/config.toml` -This is the **recommended approach** for all platforms, especially Windows where system package managers may not provide LLVM 14 development files. +This is the **recommended approach** where PECOS can provide a verified shared +LLVM package. On Debian/Ubuntu-compatible Linux distributions, PECOS downloads +the apt.llvm.org LLVM 21 packages into `~/.pecos/deps/llvm-21.1/` without using +`sudo`. On macOS, use Homebrew for LLVM 21. On Windows MSVC, use the +conda-forge helper in the Windows section below; it installs a full LLVM +development environment under `~/.pecos/deps/llvm-21.1/` and configures +`~/.pecos/deps/llvm-21.1/Library` as the LLVM prefix. + +This is a developer toolchain install: the CLI prints the install size/behavior +and asks for confirmation before downloading. Use `--yes` to accept the prompt +in scripts. Depending on platform and archive layout, the extracted toolchain +can occupy several GB. ### Option 2: System Package Manager -Install LLVM 14 using your system's package manager, then configure PECOS: +Install LLVM 21.1 using your system's package manager, then configure PECOS: === "macOS" ```bash - brew install llvm@14 + brew install llvm@21 cargo run -p pecos-cli -- llvm configure cargo build --features llvm ``` @@ -57,32 +68,41 @@ Install LLVM 14 using your system's package manager, then configure PECOS: === "Linux (Debian/Ubuntu)" ```bash sudo apt update - sudo apt install llvm-14 llvm-14-dev + sudo apt install llvm-21 llvm-21-dev cargo run -p pecos-cli -- llvm configure cargo build --features llvm ``` + If your distribution repositories do not provide LLVM 21, use the LLVM + project's Debian/Ubuntu repository at . + === "Linux (Fedora/RHEL)" ```bash - sudo dnf install llvm14 llvm14-devel + sudo dnf install llvm21 llvm21-devel cargo run -p pecos-cli -- llvm configure cargo build --features llvm ``` === "Linux (Arch)" ```bash - yay -S llvm14 # May need to build from AUR + yay -S llvm21 # May need to build from AUR cargo run -p pecos-cli -- llvm configure cargo build --features llvm ``` === "Windows" !!! warning "Windows LLVM Requirement" - The official LLVM Windows installer (`LLVM-*.exe`) is **toolchain-only** and lacks required development files (`llvm-config.exe` and headers). + The official LLVM Windows installer (`LLVM-*.exe`) is **toolchain-only** and lacks required development files (`llvm-config.exe`, headers, and `libclang.dll`). Use a full LLVM development package built for the MSVC dynamic runtime. - **Recommended:** Use Option 1 (automatic installation) above. + **Recommended for full development tests:** Use the PECOS conda-forge helper: + + ```powershell + .\scripts\ci\install-llvm-21-windows.ps1 -InstallDir "$env:USERPROFILE\.pecos\deps\llvm-21.1" + cargo run -p pecos-cli -- llvm configure "$env:USERPROFILE\.pecos\deps\llvm-21.1\Library" + cargo build --features llvm + ``` - **Alternative:** Download a full development package from: + **Alternative:** Configure another full LLVM 21.1 development package that includes `llvm-config.exe`, headers, static MSVC libraries built against the dynamic runtime, and `libclang.dll`. - [bitgate/llvm-windows-full-builds](https://github.com/bitgate/llvm-windows-full-builds) (recommended) - [vovkos/llvm-package-windows](https://github.com/vovkos/llvm-package-windows) @@ -90,8 +110,8 @@ Install LLVM 14 using your system's package manager, then configure PECOS: Extract to `C:\LLVM`, then: ```cmd - set LLVM_SYS_140_PREFIX=C:\LLVM - cargo run -p pecos-cli -- llvm configure + set LLVM_SYS_211_PREFIX=C:\LLVM + cargo run -p pecos-cli -- llvm configure C:\LLVM cargo build --features llvm ``` @@ -100,7 +120,7 @@ Install LLVM 14 using your system's package manager, then configure PECOS: After installing LLVM, you can verify the installation using these commands: ```bash -# Check if LLVM 14 is detected +# Check if LLVM 21.1 is detected cargo run -p pecos-cli -- llvm check # Show LLVM version and path @@ -110,13 +130,24 @@ cargo run -p pecos-cli -- llvm version cargo run -p pecos-cli -- llvm find ``` +`llvm check` also reports LLVM's link mode. PECOS Rust builds prefer +`libLLVM-21.so` when a shared LLVM 21.1 installation is available; static LLVM +is only suitable for targeted builds. + +For `pecos rust test` and `just dev`, PECOS requires shared LLVM. On a Linux +x86_64 developer machine, one static LLVM test link measured about 4 GB peak +RSS, while the same target linked against shared LLVM measured about 0.8 GB. +Failing early on static LLVM is intentional: full workspace tests can spawn +many LLVM-linking test binaries at once. + ## `pecos llvm` CLI Reference The `pecos llvm` subcommand provides several useful commands: ### `install` -Download and install LLVM 14.0.6 to `~/.pecos/deps/llvm-14/`: +Download and install LLVM 21.1.8 to `~/.pecos/deps/llvm-21.1/` on supported +platforms: ```bash cargo run -p pecos-cli -- install llvm @@ -134,13 +165,20 @@ Auto-configure PECOS to use detected LLVM installation: ```bash cargo run -p pecos-cli -- llvm configure + +# Or explicitly configure a user-managed LLVM installation +cargo run -p pecos-cli -- llvm configure /path/to/llvm ``` This updates `.cargo/config.toml` with the LLVM path. +Explicit paths are canonicalized before being written. If `/path/to/llvm` is a +symlink, PECOS writes the resolved LLVM directory into `.cargo/config.toml`; run +`pecos llvm configure /path/to/llvm` again after repointing that symlink. + ### `check` -Verify LLVM 14 is available: +Verify LLVM 21.1 is available: ```bash cargo run -p pecos-cli -- llvm check @@ -195,7 +233,7 @@ cargo run -p pecos-cli -- llvm tool llvm-link ### Version Requirement -PECOS specifically requires **LLVM version 14.x** (14.0.x). Other versions are not compatible with the current implementation. +PECOS specifically requires **LLVM version 21.1.x** (21.1.x). Other versions are not compatible with the current implementation. ### Configuration File @@ -203,7 +241,7 @@ The `configure` command updates `.cargo/config.toml` in the project root with: ```toml [env] -LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true } +LLVM_SYS_211_PREFIX = { value = "/path/to/llvm", force = true } ``` **Important notes:** @@ -212,43 +250,68 @@ LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true } - It's in `.gitignore` and should not be committed - The `force = true` setting ensures the configured LLVM path takes priority over environment variables +### Shared vs Static LLVM + +PECOS enables inkwell's `llvm21-1-prefer-dynamic` feature. That means Rust +builds use `libLLVM-21.so` / `libLLVM.dylib` when `llvm-config --link-shared` +can provide it. The managed installer rejects static LLVM because the normal +development test path links many LLVM-using test binaries. + +System package manager installs usually provide shared LLVM. On Debian/Ubuntu +compatible Linux distributions, the managed installer uses the apt.llvm.org +LLVM 21 packages locally under `~/.pecos/deps/llvm-21.1/`, without installing +system packages. + +When LLVM is shared, PECOS CLI commands add LLVM's `libdir` to the runtime +library path for child Cargo commands. That lets locally configured shared LLVM +installs work without editing your shell startup files. + ### Detection Priority -The `pecos llvm` tooling searches for LLVM 14 in this order: +Build commands that need to match Cargo's behavior first honor +`.cargo/config.toml` if it sets `LLVM_SYS_211_PREFIX`, then fall back to the +normal detector. The normal `pecos llvm` detector searches for LLVM 21.1 in +this order: 1. **Home directory:** - - Windows: `~/.pecos/deps/llvm-14` - - Unix: `~/.pecos/deps/llvm-14` + - Windows: `~/.pecos/deps/llvm-21.1` + - Unix: `~/.pecos/deps/llvm-21.1` + +2. **Legacy home directory:** `~/.pecos/llvm` -2. **Project-local:** `/llvm/` +3. **Project-local:** `/llvm/` -3. **System installations:** - - **macOS:** Homebrew locations (`/opt/homebrew/opt/llvm@14`, `/usr/local/opt/llvm@14`) - - **Linux:** Via `llvm-config-14` command and common paths +4. **System installations:** + - **macOS:** Homebrew locations (`/opt/homebrew/opt/llvm@21`, `/usr/local/opt/llvm@21`) + - **Linux:** Via `llvm-config-21` command and common paths - **Windows:** Common paths (`C:\Program Files\LLVM`, `C:\LLVM`, etc.) ### Platform-Specific Notes **macOS:** -- Supports both Intel and Apple Silicon architectures +- Use Homebrew LLVM 21: `brew install llvm@21` - Automatically detects Homebrew installations -- Downloads appropriate binary for each platform **Linux:** -- Detects system LLVM via `llvm-config-14` command -- Supports x86_64 and aarch64 architectures +- Detects system LLVM via `llvm-config-21` command +- Managed install uses apt.llvm.org on Debian/Ubuntu-compatible x86_64 and + aarch64 systems +- Other Linux distributions should install shared LLVM 21 through their package + manager and run `pecos llvm configure /path/to/llvm` **Windows:** -- Uses `.7z` archives for distribution -- Pure Rust extraction (no external tools required) -- Official LLVM Windows installer lacks development files - use `pecos install llvm` or community packages +- The official LLVM installer is not sufficient for PECOS development builds +- Use `scripts\ci\install-llvm-21-windows.ps1` for the conda-forge LLVM 21.1 toolchain +- Configure `~\.pecos\deps\llvm-21.1\Library`, not the conda environment root ### Security -All downloaded LLVM packages are verified with SHA256 checksums to ensure integrity. +Linux managed packages are checked against apt metadata hashes. Windows helper +packages are installed by micromamba from conda-forge, using conda package +metadata and checksums. ## Troubleshooting @@ -271,7 +334,7 @@ cargo run -p pecos-cli -- llvm version ### Wrong LLVM version detected -PECOS requires LLVM 14.x. If you have multiple LLVM versions installed, the tool will prioritize LLVM 14. Use the `find` command to see which installation is detected: +PECOS requires LLVM 21.1.x. If you have multiple LLVM versions installed, the tool will prioritize LLVM 21.1. Use the `find` command to see which installation is detected: ```bash cargo run -p pecos-cli -- llvm find @@ -283,27 +346,27 @@ If automatic configuration doesn't work, you can manually set the environment va ```bash # Unix/macOS -export LLVM_SYS_140_PREFIX=/path/to/llvm +export LLVM_SYS_211_PREFIX=/path/to/llvm # Windows -set LLVM_SYS_140_PREFIX=C:\path\to\llvm +set LLVM_SYS_211_PREFIX=C:\path\to\llvm ``` Or add to `.cargo/config.toml`: ```toml [env] -LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true } +LLVM_SYS_211_PREFIX = { value = "/path/to/llvm", force = true } ``` ## PECOS Home Directory -LLVM is installed to `~/.pecos/deps/llvm-14/`, which is part of the PECOS home directory structure: +LLVM is installed to `~/.pecos/deps/llvm-21.1/`, which is part of the PECOS home directory structure: ``` ~/.pecos/ ├── deps/ -│ ├── llvm/ # LLVM-14 installation +│ ├── llvm-21.1/ # LLVM 21.1 installation │ ├── cuda/ # CUDA Toolkit │ └── cuquantum/ # cuQuantum SDK └── cache/ # Build artifacts diff --git a/docs/user-guide/qasm-simulation.md b/docs/user-guide/qasm-simulation.md index dda6c93d3..4b8adaf03 100644 --- a/docs/user-guide/qasm-simulation.md +++ b/docs/user-guide/qasm-simulation.md @@ -89,10 +89,10 @@ Now, let's run this code using PECOS's unified `sim()` function: let program = Qasm::from_string(qasm_code); // Simple simulation - let results = sim(program.clone()).run(1000)?; + let results = sim(program.clone()).shots(1000).run()?; // With configuration - let results = sim(program).seed(42).run(1000)?; + let results = sim(program).seed(42).shots(1000).run()?; ``` ## Using the Builder API @@ -146,7 +146,7 @@ The `sim()` function returns a builder that provides flexibility through method let program = Qasm::from_string(qasm_code); // Simple simulation with builder pattern - let results = sim(program.clone()).run(1000)?; + let results = sim(program.clone()).shots(1000).run()?; // With more configuration options let results = sim(program) @@ -154,7 +154,7 @@ The `sim()` function returns a builder that provides flexibility through method .noise(DepolarizingNoiseModel::builder().with_uniform_probability(0.01)) .workers(4) // Explicitly set number of threads // .auto_workers() // Or use all available CPU cores - .run(1000)?; + .shots(1000).run()?; ``` ## Running Multiple Shots @@ -302,7 +302,7 @@ For research or to match specific hardware characteristics, you can create detai .with_seed(42); // Deterministic noise // Use with sim() - let results = sim(program).noise(noise).run(1000)?; + let results = sim(program).noise(noise).shots(1000).run()?; ``` The builder provides many configuration options including idle noise rates, leakage probabilities, @@ -357,12 +357,12 @@ PECOS provides different engines optimized for different types of circuits: // Sparse stabilizer (default, efficient for Clifford circuits) let results = sim(program.clone()) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; // State vector (for non-Clifford circuits) let results = sim(program) .quantum(state_vector()) - .run(1000)?; + .shots(1000).run()?; ``` ## Understanding Your Results @@ -419,7 +419,7 @@ Simulation results come back as measurement outcomes for each shot. These can be "#; let program = Qasm::from_string(qasm_code); - let results = sim(program).run(1000)?; + let results = sim(program).shots(1000).run()?; // Results come as ShotVec println!("Got {} shots", results.len()); @@ -568,7 +568,7 @@ Here's how to simulate a GHZ state with realistic noise: .with_seed(12345); // Deterministic noise // Run simulation - let results = sim(program).noise(noise).seed(42).run(1000)?; + let results = sim(program).noise(noise).seed(42).shots(1000).run()?; println!("GHZ state results: {:?}", results); Ok(()) @@ -624,13 +624,13 @@ For many shots, you can use multiple CPU cores to speed up simulation: let program = Qasm::from_string(qasm_code); // Single threaded (default) - let results = sim(program.clone()).workers(1).run(1000)?; + let results = sim(program.clone()).workers(1).shots(1000).run()?; // Explicit thread count - let results = sim(program.clone()).workers(4).run(1000)?; + let results = sim(program.clone()).workers(4).shots(1000).run()?; // Automatically use all available cores - let results = sim(program).auto_workers().run(1000)?; + let results = sim(program).auto_workers().shots(1000).run()?; ``` ### Choosing the Right Engine diff --git a/docs/user-guide/simulators.md b/docs/user-guide/simulators.md index 9b0270b70..cb7d0c386 100644 --- a/docs/user-guide/simulators.md +++ b/docs/user-guide/simulators.md @@ -140,12 +140,12 @@ The default simulator, optimized for QEC workloads with sparse stabilizer tablea ```rust // SparseStab is used by default - let results = sim(program.clone()).run(1000)?; + let results = sim(program.clone()).shots(1000).run()?; // Or explicitly select it let results = sim(program) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; ``` **Strengths:** @@ -209,7 +209,7 @@ Pure Rust state vector implementation. ```rust let results = sim(program) .quantum(state_vector()) - .run(100)?; + .shots(100).run()?; ``` **Strengths:** @@ -431,16 +431,16 @@ The `sim()` API lets you switch simulators easily: "#); // Default (sparse stabilizer for Clifford circuits) - let results = sim(circuit.clone()).run(1000)?; + let results = sim(circuit.clone()).shots(1000).run()?; // Explicit simulator selection let results = sim(circuit.clone()) .quantum(state_vector()) - .run(1000)?; + .shots(1000).run()?; let results = sim(circuit) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; ``` ## Direct Simulator Access diff --git a/examples/surface/brickwork_sweep.py b/examples/surface/brickwork_sweep.py index 88fe9f01e..55d501e3a 100644 --- a/examples/surface/brickwork_sweep.py +++ b/examples/surface/brickwork_sweep.py @@ -8,7 +8,7 @@ are unambiguous. Decoder names: - observable_subgraph:INNER -- OSD with inner decoder (pymatching, pecos_uf:fast, etc.) + logical_subgraph:INNER -- logical-subgraph decoder with inner decoder (pymatching, pecos_uf:fast, etc.) logical_circuit:BUDGET:INNER -- LogicalCircuitDecoder (unlimited, windowed, 10ms, etc.) logical_algorithm:INNER -- LogicalAlgorithmDecoder (full-circuit, no budget) @@ -16,13 +16,13 @@ uv run python examples/surface/brickwork_sweep.py \ --distances 3 5 --widths 2 3 4 --depths 1 2 3 \ --error-rates 0.001 0.002 \ - --decoders observable_subgraph:pymatching \ + --decoders logical_subgraph:pymatching \ --shots 5000 --output-dir /tmp/brickwork_sweep uv run python examples/surface/brickwork_sweep.py \ --distances 3 5 --widths 2 3 --depths 1 2 \ --error-rates 0.001 \ - --decoders observable_subgraph:pymatching logical_circuit:windowed:pymatching \ + --decoders logical_subgraph:pymatching logical_circuit:windowed:pymatching \ --shots 2000 --save-html --open """ @@ -159,7 +159,7 @@ def run_sweep( ) -> BrickworkShard: """Run the full brickwork sweep.""" from pecos.qec.surface import SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem config = { "distances": distances, @@ -230,19 +230,19 @@ def run_sweep( desc = b.build_algorithm_descriptor(p1=p, p2=p, p_meas=p, p_prep=p) algo = LogicalAlgorithmDecoder(desc, inner) errors = algo.decode_count(batch) - elif decoder_name.startswith("observable_subgraph"): + elif decoder_name.startswith("logical_subgraph"): parts = decoder_name.split(":", 1) inner = parts[1] if len(parts) > 1 else "pymatching" - osd = ObservableSubgraphDecoder(dem_str, sc, inner) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner) # Use parallel decode for large shot counts if shots >= 5000: - errors = osd.decode_count_parallel(batch, dem_str, sc, inner) + errors = decoder.decode_count_parallel(batch, dem_str, sc, inner) else: - errors = osd.decode_count(batch) + errors = decoder.decode_count(batch) else: msg = ( f"Unknown decoder: '{decoder_name}'. " - f"Supported: observable_subgraph:INNER, " + f"Supported: logical_subgraph:INNER, " f"logical_circuit:BUDGET:INNER, " f"logical_algorithm:INNER" ) @@ -430,9 +430,9 @@ def write_html_report(shard: BrickworkShard, path: Path, coherent_results=None) "

Budget strategies

", "

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

", "
    ", - "
  • unlimited — Full-circuit OSD. Maximum accuracy. " + "
  • unlimited — Full-circuit logical-subgraph decoder. Maximum accuracy. " "Appropriate for Clifford circuits or offline analysis.
  • ", - "
  • windowed / 10ms — Windowed OSD with " + "
  • windowed / 10ms — Windowed logical-subgraph decoder with " "overlap buffers inside each per-observable subgraph. Bounded latency, full " "accuracy with sufficient overlap.
  • ", "
  • 100us / 1us — Tight budget. Windowed " @@ -592,7 +592,7 @@ def main(): help="Override --depths: set depth=2^((d+1)/2) per distance", ) parser.add_argument("--error-rates", type=float, nargs="+", default=[0.001]) - parser.add_argument("--decoders", nargs="+", default=["observable_subgraph:pymatching"]) + parser.add_argument("--decoders", nargs="+", default=["logical_subgraph:pymatching"]) parser.add_argument("--shots", type=int, default=5000) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--rounds-per-layer", type=int, default=2) @@ -680,7 +680,7 @@ def main(): # T-injection circuits if args.include_t_injection or args.t_injection_only: from pecos.qec.surface import SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem print("\n--- T-Injection Circuits ---") for d in args.distances: @@ -721,15 +721,15 @@ def main(): desc = b.build_algorithm_descriptor(p1=p, p2=p, p_meas=p, p_prep=p) algo = LogicalAlgorithmDecoder(desc, inner) errors = algo.decode_count(batch) - elif decoder_name.startswith("observable_subgraph"): + elif decoder_name.startswith("logical_subgraph"): parts = decoder_name.split(":", 1) inner = parts[1] if len(parts) > 1 else "pymatching" - osd = ObservableSubgraphDecoder(dem_str, sc, inner) - errors = osd.decode_count(batch) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner) + errors = decoder.decode_count(batch) else: msg = ( f"Unknown decoder: '{decoder_name}'. " - f"Supported: observable_subgraph:INNER, " + f"Supported: logical_subgraph:INNER, " f"logical_circuit:BUDGET:INNER, " f"logical_algorithm:INNER" ) diff --git a/examples/surface/coherent_noise_sweep.py b/examples/surface/coherent_noise_sweep.py index 1995878c6..63c89a56c 100644 --- a/examples/surface/coherent_noise_sweep.py +++ b/examples/surface/coherent_noise_sweep.py @@ -68,7 +68,7 @@ def run_sweep( ) -> CoherentNoiseSweep: """Run a coherent noise sweep using sim_neo.""" from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder + from pecos_rslib.qec import LogicalSubgraphDecoder from pecos_rslib_exp import depolarizing, sim_neo, stab_mps, statevec patch = SurfacePatch.create(distance=distance) @@ -82,7 +82,7 @@ def run_sweep( num_meas = int(tc.get_meta("num_measurements")) dem_str = b.build_dem(p1=p_depol, p2=p_depol, p_meas=p_depol, p_prep=p_depol) sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pymatching") + decoder = LogicalSubgraphDecoder(dem_str, sc, "pymatching") sweep = CoherentNoiseSweep( distance=distance, @@ -133,7 +133,7 @@ def run_sweep( val ^= meas[idx] if val: obs_mask |= 1 << obs["id"] - pred = osd.decode([int(x) for x in det_events]) + pred = decoder.decode([int(x) for x in det_events]) if pred != obs_mask: errors += 1 decode_time = time.perf_counter() - t0 diff --git a/examples/surface/compare_surface_sweep_json.py b/examples/surface/compare_surface_sweep_json.py new file mode 100644 index 000000000..c7407bd7c --- /dev/null +++ b/examples/surface/compare_surface_sweep_json.py @@ -0,0 +1,391 @@ +"""Compare two surface-sweep JSON artifacts. + +This helper reads the JSON files emitted by ``native_dem_threshold_sweep.py`` +and prints Markdown tables with matched logical-error rates, binomial +intervals, ratios, differences, and descriptive normal-approximation z-scores. +It is useful for comparing CX vs SZZ/SZZdg surface-code runs that used the same +sweep grid. + +Example: + python examples/surface/compare_surface_sweep_json.py \\ + /tmp/pecos-szz-validation/native_cx_d357_r1_5k_results.json \\ + /tmp/pecos-szz-validation/native_szz_d357_r1_5k_results.json \\ + --left-label CX --right-label SZZ +""" + +from __future__ import annotations + +import argparse +import json +import math +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +Z_95 = 1.959963984540054 +CI_METHODS = {"jeffreys", "wilson"} + + +@dataclass(frozen=True) +class Point: + backend: str + basis: str + distance: int + p: float + rounds: int + errors: int + shots: int + + @property + def rate(self) -> float: + return self.errors / self.shots if self.shots else math.nan + + +@dataclass(frozen=True) +class Comparison: + key: tuple[str, str, int, float, int] + left: Point + right: Point + + +def _as_point(raw: dict[str, Any]) -> Point: + return Point( + backend=str(raw.get("backend", "")), + basis=str(raw["basis"]).upper(), + distance=int(raw["distance"]), + p=float(raw["physical_error_rate"]), + rounds=int(raw["total_rounds"]), + errors=int(raw["num_logical_errors"]), + shots=int(raw["num_shots"]), + ) + + +def load_points(path: Path) -> dict[tuple[str, str, int, float, int], Point]: + data = json.loads(path.read_text()) + points = data.get("points") + if not isinstance(points, list): + msg = f"{path} does not contain a list-valued 'points' field" + raise TypeError(msg) + + loaded: dict[tuple[str, str, int, float, int], Point] = {} + for raw in points: + point = _as_point(raw) + key = (point.backend, point.basis, point.distance, point.p, point.rounds) + if key in loaded: + msg = f"{path} contains duplicate point key {key}" + raise ValueError(msg) + loaded[key] = point + return loaded + + +def wilson_interval(errors: int, shots: int, z: float = Z_95) -> tuple[float, float]: + if shots <= 0: + return math.nan, math.nan + phat = errors / shots + denom = 1.0 + z * z / shots + center = (phat + z * z / (2.0 * shots)) / denom + half = z * math.sqrt((phat * (1.0 - phat) + z * z / (4.0 * shots)) / shots) / denom + return max(0.0, center - half), min(1.0, center + half) + + +def jeffreys_interval(errors: int, shots: int, confidence: float = 0.95) -> tuple[float, float]: + """Return a Jeffreys equal-tailed interval for one binomial proportion.""" + if shots <= 0: + return math.nan, math.nan + from scipy.stats import beta + + alpha = (1.0 - confidence) / 2.0 + lower = 0.0 if errors == 0 else float(beta.ppf(alpha, errors + 0.5, shots - errors + 0.5)) + upper = 1.0 if errors == shots else float(beta.ppf(1.0 - alpha, errors + 0.5, shots - errors + 0.5)) + return lower, upper + + +def binomial_interval(errors: int, shots: int, method: str) -> tuple[float, float]: + if method == "jeffreys": + return jeffreys_interval(errors, shots) + if method == "wilson": + return wilson_interval(errors, shots) + msg = f"unknown interval method {method!r}" + raise ValueError(msg) + + +def standard_error(errors: int, shots: int) -> float: + if shots <= 0: + return math.nan + rate = errors / shots + return math.sqrt(rate * (1.0 - rate) / shots) + + +def z_score(left: Point, right: Point) -> float: + denom = math.sqrt( + standard_error(left.errors, left.shots) ** 2 + standard_error(right.errors, right.shots) ** 2, + ) + if denom == 0.0: + return math.nan + return (right.rate - left.rate) / denom + + +def ratio(left: Point, right: Point) -> float: + if left.rate == 0.0: + return math.inf if right.rate > 0.0 else 1.0 + return right.rate / left.rate + + +def format_rate(point: Point, *, include_ci: bool, interval_method: str) -> str: + base = f"{point.rate:.4g} ({point.errors}/{point.shots})" + if not include_ci: + return base + low, high = binomial_interval(point.errors, point.shots, interval_method) + return f"{base} [{low:.4g}, {high:.4g}]" + + +def format_float(value: float, precision: int = 2) -> str: + if math.isnan(value): + return "nan" + if math.isinf(value): + return "inf" + return f"{value:.{precision}f}" + + +def matched_comparisons( + left: dict[tuple[str, str, int, float, int], Point], + right: dict[tuple[str, str, int, float, int], Point], +) -> list[Comparison]: + return [Comparison(key, left[key], right[key]) for key in sorted(set(left) & set(right))] + + +def aggregate_points(points: list[Point]) -> Point: + if not points: + msg = "cannot aggregate an empty point list" + raise ValueError(msg) + first = points[0] + return Point( + backend=first.backend, + basis=first.basis, + distance=first.distance, + p=math.nan, + rounds=first.rounds, + errors=sum(point.errors for point in points), + shots=sum(point.shots for point in points), + ) + + +def emit_point_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, + interval_method: str, +) -> str: + lines = [ + "## Matched Points", + "", + f"| backend | basis | d | rounds | p | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|---|--------|---|------|------|-------|------|---|", + ] + for comparison in comparisons: + backend, basis, distance, p, rounds = comparison.key + left = comparison.left + right = comparison.right + lines.append( + "| " + f"{backend} | {basis} | {distance} | {rounds} | {p:g} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def emit_aggregate_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, + interval_method: str, +) -> str: + grouped: dict[tuple[str, str, int, int], list[Comparison]] = defaultdict(list) + for comparison in comparisons: + backend, basis, distance, _p, rounds = comparison.key + grouped[(backend, basis, distance, rounds)].append(comparison) + + lines = [ + "## Aggregate Over Physical Error Rates", + "", + f"| backend | basis | d | rounds | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|---|--------|------|------|-------|------|---|", + ] + for key in sorted(grouped): + backend, basis, distance, rounds = key + group = grouped[key] + left = aggregate_points([comparison.left for comparison in group]) + right = aggregate_points([comparison.right for comparison in group]) + lines.append( + "| " + f"{backend} | {basis} | {distance} | {rounds} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def emit_cross_distance_pooled_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, + interval_method: str, +) -> str: + grouped: dict[tuple[str, str], list[Comparison]] = defaultdict(list) + for comparison in comparisons: + backend, basis, _distance, _p, _rounds = comparison.key + grouped[(backend, basis)].append(comparison) + + lines = [ + "## Pooled Across Distances By Backend And Basis", + "", + "This table intentionally pools across distances. It is useful as a rough", + "event-count summary, but it is dominated by lower-distance points and is", + "not a scaling or threshold statement.", + "", + f"| backend | basis | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|------|------|-------|------|---|", + ] + for key in sorted(grouped): + backend, basis = key + group = grouped[key] + left = aggregate_points([comparison.left for comparison in group]) + right = aggregate_points([comparison.right for comparison in group]) + lines.append( + "| " + f"{backend} | {basis} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def build_report( + left_path: Path, + right_path: Path, + *, + left_label: str, + right_label: str, + include_ci: bool, + interval_method: str, + include_cross_distance_pooled: bool, +) -> str: + left = load_points(left_path) + right = load_points(right_path) + comparisons = matched_comparisons(left, right) + if not comparisons: + msg = "No common (backend, basis, distance, p, rounds) points found" + raise ValueError(msg) + + left_only = len(set(left) - set(right)) + right_only = len(set(right) - set(left)) + lines = [ + f"# Sweep Comparison: {left_label} vs {right_label}", + "", + f"- left: `{left_path}`", + f"- right: `{right_path}`", + f"- matched points: {len(comparisons)}", + f"- left-only points: {left_only}", + f"- right-only points: {right_only}", + f"- intervals: {'none' if not include_ci else f'{interval_method} 95%'}", + "- z-scores: descriptive unpooled Wald z-scores, uncorrected for multiple comparisons", + "- read low-count and zero-count rows cautiously", + "- cross-distance pooled totals are omitted by default; use " + "`--include-cross-distance-pooled` for a rough event-count summary", + "", + emit_point_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + interval_method=interval_method, + ), + "", + emit_aggregate_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + interval_method=interval_method, + ), + ] + if include_cross_distance_pooled: + lines.extend( + [ + "", + emit_cross_distance_pooled_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + interval_method=interval_method, + ), + ], + ) + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("left", type=Path, help="First native_dem_threshold_sweep.py JSON artifact.") + parser.add_argument("right", type=Path, help="Second native_dem_threshold_sweep.py JSON artifact.") + parser.add_argument("--left-label", default="left", help="Label for the first artifact.") + parser.add_argument("--right-label", default="right", help="Label for the second artifact.") + parser.add_argument( + "--ci", + choices=sorted(CI_METHODS), + default="jeffreys", + help="Binomial interval method to report when intervals are enabled.", + ) + parser.add_argument("--no-ci", action="store_true", help="Omit 95% binomial intervals.") + parser.add_argument( + "--include-cross-distance-pooled", + action="store_true", + help=( + "Also emit totals pooled across all distances by backend and basis. " + "This is not a scaling or threshold summary." + ), + ) + parser.add_argument("--output", type=Path, default=None, help="Optional Markdown output path.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + report = build_report( + args.left, + args.right, + left_label=args.left_label, + right_label=args.right_label, + include_ci=not args.no_ci, + interval_method=args.ci, + include_cross_distance_pooled=args.include_cross_distance_pooled, + ) + if args.output is None: + print(report) + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(report + "\n") + print(f"Wrote comparison report to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py new file mode 100644 index 000000000..bde4bdfad --- /dev/null +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -0,0 +1,971 @@ +"""Diagnose raw DEMs and graphlike decompositions for traced-QIS surface circuits. + +The script keeps sampling fixed: each case samples once from the exact native +influence-model DEM, then decodes the same detector events with several decoder +views of the model. The graphlike views are lossy hyperedge-to-edge projections +for graph decoders, so this separates raw DEM generation from graphlike +decomposition quality. + +Example: + uv run python examples/surface/dem_decomposition_diagnostics.py \\ + --distances 3 5 --bases X Z --interaction-bases cx szz \\ + --p 0.006 --shots 10000 --tesseract-beams 5 20 +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +ERROR_RE = re.compile(r"error\(([^)]+)\)\s*(.*)") +DET_RE = re.compile(r"\bD(\d+)\b") +OBS_RE = re.compile(r"\bL(\d+)\b") +DETECTOR_COORD_RE = re.compile(r"detector\(([^)]*)\) D(\d+)") +GRAPHLIKE_DECODER_CHOICES = [ + "native_decomp_pymatching", + "native_decomp_pymatching_correlated", + "stim_decomp_pymatching", + "stim_decomp_pymatching_correlated", + "terminal_decomp_pymatching", + "terminal_decomp_pymatching_correlated", +] +# The staged SZZ device model treats Z/SZ/SZdg frame updates as noiseless +# virtual operations. CX-vs-SZZ p1 location comparisons include this assumption +# as well as the gate-basis difference. +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} +RESULT_SCHEMA_VERSION = 2 + + +@dataclass(frozen=True) +class DemStats: + error_lines: int + probability_sum: float + separator_lines: int + hyperedge_lines: int + logical_lines: int + max_component_detectors: int + max_line_detectors: int + pure_logical_components: int + + +@dataclass(frozen=True) +class RawDemComparison: + native_errors: int + stim_errors: int + only_native: int + only_stim: int + common: int + max_abs_probability_diff: float + max_rel_probability_diff: float + l1_probability_diff: float + + +@dataclass(frozen=True) +class DecodeSummary: + decoder: str + logical_errors: int + logical_error_rate: float + elapsed_s: float + + +@dataclass(frozen=True) +class PairAnalysisSummary: + decoder: str + pair_probability_mass: float + wrong_probability_mass: float + wrong_probability_fraction: float + disagree_tesseract_probability_mass: float + disagree_tesseract_probability_fraction: float + wrong_count: int + disagree_tesseract_count: int + + +@dataclass(frozen=True) +class CaseResult: + result_schema_version: int + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + shots: int + seed: int + pair_analysis_requested: bool + pair_analysis_max_effects: int + raw_comparison: RawDemComparison + dem_stats: dict[str, DemStats] + decoders: list[DecodeSummary] + pair_analysis: list[PairAnalysisSummary] | None + + +def _combine_independent_probabilities(left: float, right: float) -> float: + """Combine independent mechanisms with the same XOR effect.""" + return left * (1.0 - right) + right * (1.0 - left) + + +def _toggle(values: set[int], value: int) -> None: + if value in values: + values.remove(value) + else: + values.add(value) + + +def _canonical_effect_key(targets: str) -> str: + """Canonicalize DEM targets by XORing all ``^`` components.""" + detectors: set[int] = set() + observables: set[int] = set() + for component in targets.split("^"): + for detector in DET_RE.findall(component): + _toggle(detectors, int(detector)) + for observable in OBS_RE.findall(component): + _toggle(observables, int(observable)) + tokens = [f"D{det}" for det in sorted(detectors)] + tokens.extend(f"L{obs}" for obs in sorted(observables)) + return " ".join(tokens) + + +def dem_effect_probabilities(dem_text: str) -> dict[str, float]: + """Aggregate DEM error probabilities by combined detector/observable effect.""" + effects: dict[str, float] = {} + for line in dem_text.splitlines(): + match = ERROR_RE.match(line.strip()) + if not match: + continue + probability = float(match.group(1)) + key = _canonical_effect_key(match.group(2)) + if not key: + continue + effects[key] = _combine_independent_probabilities(effects.get(key, 0.0), probability) + return effects + + +def compare_raw_dems(native_dem: str, stim_dem: str) -> RawDemComparison: + """Compare raw native and Stim DEMs after aggregating duplicate effects. + + ``only_native`` and ``only_stim`` report structural differences. Nonzero + probability deltas with zero structural differences reflect independent + probability-combination and serialization-rounding conventions. + """ + native = dem_effect_probabilities(native_dem) + stim = dem_effect_probabilities(stim_dem) + native_keys = set(native) + stim_keys = set(stim) + common = native_keys & stim_keys + + max_abs = 0.0 + max_rel = 0.0 + l1 = 0.0 + for key in common: + diff = abs(native[key] - stim[key]) + max_abs = max(max_abs, diff) + max_rel = max(max_rel, diff / max(native[key], stim[key], 1e-18)) + l1 += diff + for key in native_keys - stim_keys: + l1 += native[key] + for key in stim_keys - native_keys: + l1 += stim[key] + + return RawDemComparison( + native_errors=len(native), + stim_errors=len(stim), + only_native=len(native_keys - stim_keys), + only_stim=len(stim_keys - native_keys), + common=len(common), + max_abs_probability_diff=max_abs, + max_rel_probability_diff=max_rel, + l1_probability_diff=l1, + ) + + +def parse_detector_coords(dem_text: str) -> dict[int, tuple[float, ...]]: + """Parse detector coordinate annotations from DEM text.""" + coords: dict[int, tuple[float, ...]] = {} + for line in dem_text.splitlines(): + match = DETECTOR_COORD_RE.match(line.strip()) + if not match: + continue + values = tuple(float(value.strip()) for value in match.group(1).split(",") if value.strip()) + coords[int(match.group(2))] = values + return coords + + +def detector_coord_distance( + left: int, + right: int, + coords: dict[int, tuple[float, ...]], +) -> float: + """Coordinate distance with detector-id fallback for missing annotations.""" + left_coords = coords.get(left, (float(left),)) + right_coords = coords.get(right, (float(right),)) + dims = max(len(left_coords), len(right_coords)) + left_coords = left_coords + (0.0,) * (dims - len(left_coords)) + right_coords = right_coords + (0.0,) * (dims - len(right_coords)) + return math.sqrt(sum((a - b) ** 2 for a, b in zip(left_coords, right_coords, strict=True))) + + +def min_coord_terminal_pairs( + detectors: tuple[int, ...], + coords: dict[int, tuple[float, ...]], +) -> tuple[list[tuple[int, int]], list[int]]: + """Pair terminals by minimum coordinate distance, leaving one singleton if odd.""" + if len(detectors) <= 1: + return [], list(detectors) + if len(detectors) == 2: + return [(detectors[0], detectors[1])], [] + + best_cost: float | None = None + best_pairs: list[tuple[int, int]] = [] + best_singles: list[int] = [] + for left_index, left in enumerate(detectors): + for right in detectors[left_index + 1 :]: + rest = tuple(detector for detector in detectors if detector not in {left, right}) + pairs, singles = min_coord_terminal_pairs(rest, coords) + pairs = [(left, right), *pairs] + cost = sum(detector_coord_distance(a, b, coords) for a, b in pairs) + if best_cost is None or cost < best_cost: + best_cost = cost + best_pairs = pairs + best_singles = singles + return best_pairs, best_singles + + +def terminal_graphlike_projection(raw_dem: str) -> str: + """Project raw DEM effects into minimum-span terminal-only graphlike pieces. + + Each raw mechanism keeps its combined detector/observable effect exactly, + but the rendered decomposition uses only detectors present in that raw + effect. This avoids cancellation/path detectors introduced by graph-path + decompositions while still producing graphlike components for matching + decoders. The result is a lossy decoder-facing projection of hyperedge + correlations, not an exact raw DEM. + """ + coords = parse_detector_coords(raw_dem) + annotation_lines: list[str] = [] + by_targets: dict[str, float] = {} + + for line in raw_dem.splitlines(): + stripped = line.strip() + if stripped.startswith(("detector", "logical_observable")): + annotation_lines.append(line) + continue + + match = ERROR_RE.match(stripped) + if not match: + continue + probability = float(match.group(1)) + effect = _canonical_effect_key(match.group(2)) + detectors = tuple(sorted(int(detector) for detector in DET_RE.findall(effect))) + observables = sorted(int(observable) for observable in OBS_RE.findall(effect)) + pairs, singles = min_coord_terminal_pairs(detectors, coords) + + components = [f"D{left} D{right}" for left, right in pairs] + components.extend(f"D{detector}" for detector in singles) + if not components and observables: + # PyMatching cannot use a pure logical component. Preserve the raw + # effect so construction fails instead of silently changing it. + components = [f"L{observable}" for observable in observables] + else: + for observable in observables: + components[-1] = f"{components[-1]} L{observable}" + + rendered_targets = " ^ ".join(components) + if rendered_targets: + by_targets[rendered_targets] = _combine_independent_probabilities( + by_targets.get(rendered_targets, 0.0), + probability, + ) + + rendered_lines = [ + f"error({probability:.16g}) {targets}" + for targets, probability in sorted(by_targets.items()) + if probability > 0.0 + ] + return "\n".join([*annotation_lines, *rendered_lines]) + + +def dem_stats(dem_text: str) -> DemStats: + """Summarize the structure of a DEM string.""" + error_lines = 0 + probability_sum = 0.0 + separator_lines = 0 + hyperedge_lines = 0 + logical_lines = 0 + max_component_detectors = 0 + max_line_detectors = 0 + pure_logical_components = 0 + + for line in dem_text.splitlines(): + match = ERROR_RE.match(line.strip()) + if not match: + continue + error_lines += 1 + probability_sum += float(match.group(1)) + targets = match.group(2) + components = targets.split(" ^ ") + if len(components) > 1: + separator_lines += 1 + if "L" in targets: + logical_lines += 1 + + line_detectors = len(DET_RE.findall(targets)) + max_line_detectors = max(max_line_detectors, line_detectors) + if line_detectors > 2: + hyperedge_lines += 1 + + for component in components: + component_detectors = len(DET_RE.findall(component)) + component_observables = len(OBS_RE.findall(component)) + max_component_detectors = max(max_component_detectors, component_detectors) + if component_detectors == 0 and component_observables > 0: + pure_logical_components += 1 + + return DemStats( + error_lines=error_lines, + probability_sum=probability_sum, + separator_lines=separator_lines, + hyperedge_lines=hyperedge_lines, + logical_lines=logical_lines, + max_component_detectors=max_component_detectors, + max_line_detectors=max_line_detectors, + pure_logical_components=pure_logical_components, + ) + + +def strip_logical_observable_lines(dem_text: str) -> str: + return "\n".join(line for line in dem_text.splitlines() if not line.startswith("logical_observable")) + + +def true_observable_flips(observable_flips: np.ndarray) -> np.ndarray: + if observable_flips.ndim == 1: + return observable_flips.astype(np.uint8) + if observable_flips.shape[1] == 0: + return np.zeros(observable_flips.shape[0], dtype=np.uint8) + return observable_flips[:, 0].astype(np.uint8) + + +def dense_effect_arrays(effects: dict[str, float]) -> tuple[list[str], np.ndarray, np.ndarray, np.ndarray]: + """Convert effect keys into dense detector rows and observable flips.""" + keys = list(effects) + probabilities = np.array([effects[key] for key in keys], dtype=float) + max_detector = max((int(detector) for key in keys for detector in DET_RE.findall(key)), default=-1) + detection_events = np.zeros((len(keys), max_detector + 1), dtype=np.uint8) + observable_flips = np.zeros(len(keys), dtype=np.uint8) + + for index, key in enumerate(keys): + detectors = [int(detector) for detector in DET_RE.findall(key)] + if detectors: + detection_events[index, detectors] = 1 + observable_flips[index] = 1 if "0" in OBS_RE.findall(key) else 0 + + return keys, probabilities, detection_events, observable_flips + + +def tesseract_predictions(dem_text: str, detection_events: np.ndarray, *, beam: int) -> np.ndarray: + from pecos.decoders import TesseractDecoder + + decoder = TesseractDecoder.from_dem( + strip_logical_observable_lines(dem_text), + preset="fast", + det_beam=beam, + ) + results = decoder.decode_batch([row.tolist() for row in detection_events]) + return np.array([int(result.observables_mask & 1) for result in results], dtype=np.uint8) + + +def pymatching_predictions(dem_text: str, detection_events: np.ndarray, *, correlated: bool) -> np.ndarray: + from pecos.decoders import PyMatchingDecoder + + if correlated: + decoder = PyMatchingDecoder.from_dem_with_correlations(dem_text, enable_correlations=True) + else: + decoder = PyMatchingDecoder.from_dem(dem_text) + predictions = decoder.decode_batch( + detection_events.astype(np.uint8).flatten().tolist(), + len(detection_events), + ) + return np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + + +def decode_with_tesseract( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + beam: int, +) -> int: + expected = true_observable_flips(observable_flips) + predicted = tesseract_predictions(dem_text, detection_events, beam=beam) + return int(np.sum(predicted != expected)) + + +def decode_with_pymatching( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + correlated: bool, +) -> int: + expected = true_observable_flips(observable_flips) + predicted = pymatching_predictions(dem_text, detection_events, correlated=correlated) + return int(np.sum(predicted != expected)) + + +def _timed_decode(label: str, callback: Any, shots: int) -> DecodeSummary: + start = time.perf_counter() + errors = int(callback()) + elapsed = time.perf_counter() - start + return DecodeSummary( + decoder=label, + logical_errors=errors, + logical_error_rate=errors / shots if shots else 0.0, + elapsed_s=elapsed, + ) + + +def two_fault_pair_analysis( + *, + native_raw: str, + native_decomposed: str, + stim_decomposed: str, + terminal_decomposed: str, + max_effects: int, +) -> list[PairAnalysisSummary] | None: + """Exhaustively compare decoders on all two-mechanism XOR combinations.""" + effects = dem_effect_probabilities(native_raw) + keys, probabilities, detection_events, observable_flips = dense_effect_arrays(effects) + if len(keys) > max_effects: + return None + + pair_rows: list[np.ndarray] = [] + pair_observables: list[int] = [] + pair_weights: list[float] = [] + for left in range(len(keys)): + for right in range(left + 1, len(keys)): + pair_rows.append(detection_events[left] ^ detection_events[right]) + pair_observables.append(int(observable_flips[left] ^ observable_flips[right])) + pair_weights.append(float(probabilities[left] * probabilities[right])) + + if not pair_rows: + return [] + + pair_detection_events = np.asarray(pair_rows, dtype=np.uint8) + pair_observable_flips = np.asarray(pair_observables, dtype=np.uint8) + weights = np.asarray(pair_weights, dtype=float) + total_weight = float(np.sum(weights)) + + predictions = { + "native_raw_tesseract_b5": tesseract_predictions(native_raw, pair_detection_events, beam=5), + "native_decomp_pymatching": pymatching_predictions( + native_decomposed, + pair_detection_events, + correlated=False, + ), + "native_decomp_pymatching_correlated": pymatching_predictions( + native_decomposed, + pair_detection_events, + correlated=True, + ), + "stim_decomp_pymatching": pymatching_predictions( + stim_decomposed, + pair_detection_events, + correlated=False, + ), + "stim_decomp_pymatching_correlated": pymatching_predictions( + stim_decomposed, + pair_detection_events, + correlated=True, + ), + "terminal_decomp_pymatching": pymatching_predictions( + terminal_decomposed, + pair_detection_events, + correlated=False, + ), + "terminal_decomp_pymatching_correlated": pymatching_predictions( + terminal_decomposed, + pair_detection_events, + correlated=True, + ), + } + reference = predictions["native_raw_tesseract_b5"] + + summaries = [] + for name, predicted in predictions.items(): + wrong = predicted != pair_observable_flips + disagree = predicted != reference + wrong_mass = float(np.sum(weights[wrong])) + disagree_mass = float(np.sum(weights[disagree])) + summaries.append( + PairAnalysisSummary( + decoder=name, + pair_probability_mass=total_weight, + wrong_probability_mass=wrong_mass, + wrong_probability_fraction=wrong_mass / total_weight if total_weight else 0.0, + disagree_tesseract_probability_mass=disagree_mass, + disagree_tesseract_probability_fraction=disagree_mass / total_weight if total_weight else 0.0, + wrong_count=int(np.sum(wrong)), + disagree_tesseract_count=int(np.sum(disagree)), + ), + ) + return summaries + + +def run_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + tesseract_beams: list[int], + decoder_names: set[str], + pair_analysis: bool, + pair_analysis_max_effects: int, +) -> CaseResult: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, + ) + from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, + ) + + patch = SurfacePatch.create(distance=distance) + noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise_args = { + "p1": noise.p1, + "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, + "p2": noise.p2, + "p_meas": noise.p_meas, + "p_prep": noise.p_prep, + } + + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="DEM decomposition diagnostics") + + native_raw = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + native_decomposed = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + try: + terminal_decomposed = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="terminal_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + except RuntimeError as exc: + if "terminal graphlike" not in str(exc): + raise + terminal_decomposed = terminal_graphlike_projection(native_raw) + stim_raw = generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ) + stim_decomposed = generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=True, + **noise_args, + ) + + sampler = build_native_sampler( + patch, + rounds, + noise, + basis=basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + sampling_model="influence_dem", + ) + detection_events, observable_flips = sampler.sample(num_shots=shots, seed=seed) + + decoders = [ + _timed_decode( + f"native_raw_tesseract_b{beam}", + lambda beam=beam: decode_with_tesseract( + native_raw, + detection_events, + observable_flips, + beam=beam, + ), + shots, + ) + for beam in tesseract_beams + ] + graphlike_decoder_specs = [ + ( + "native_decomp_pymatching", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + ), + ( + "native_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + ), + ( + "stim_decomp_pymatching", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + ), + ( + "stim_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + ), + ( + "terminal_decomp_pymatching", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + ), + ( + "terminal_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + ), + ] + decoders.extend( + _timed_decode(name, callback, shots) for name, callback in graphlike_decoder_specs if name in decoder_names + ) + + return CaseResult( + result_schema_version=RESULT_SCHEMA_VERSION, + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=shots, + seed=seed, + pair_analysis_requested=pair_analysis, + pair_analysis_max_effects=pair_analysis_max_effects, + raw_comparison=compare_raw_dems(native_raw, stim_raw), + dem_stats={ + "native_raw": dem_stats(native_raw), + "native_decomposed": dem_stats(native_decomposed), + "stim_raw": dem_stats(stim_raw), + "stim_decomposed": dem_stats(stim_decomposed), + "terminal_decomposed": dem_stats(terminal_decomposed), + }, + decoders=decoders, + pair_analysis=( + two_fault_pair_analysis( + native_raw=native_raw, + native_decomposed=native_decomposed, + stim_decomposed=stim_decomposed, + terminal_decomposed=terminal_decomposed, + max_effects=pair_analysis_max_effects, + ) + if pair_analysis + else None + ), + ) + + +def print_case(result: CaseResult) -> None: + print( + f"\n=== d={result.distance} r={result.rounds} basis={result.basis} " + f"basis2q={result.interaction_basis} p={result.p:g} shots={result.shots} ===", + ) + raw = result.raw_comparison + print( + "raw native vs Stim: " + f"native={raw.native_errors} stim={raw.stim_errors} " + f"only_native={raw.only_native} only_stim={raw.only_stim} " + f"max_rel={raw.max_rel_probability_diff:.3e} " + f"l1={raw.l1_probability_diff:.3e}", + ) + if raw.only_native == 0 and raw.only_stim == 0 and raw.max_rel_probability_diff > 0: + print( + " raw structures match; probability deltas reflect combination/rounding conventions.", + ) + + print("DEM stats:") + print(" source errors psum sep hyper max_comp max_line pure_L") + for name, stats in result.dem_stats.items(): + print( + f" {name:<18} {stats.error_lines:6d} {stats.probability_sum:10.6f} " + f"{stats.separator_lines:5d} {stats.hyperedge_lines:5d} " + f"{stats.max_component_detectors:8d} {stats.max_line_detectors:8d} " + f"{stats.pure_logical_components:6d}", + ) + + print("Decode on identical raw influence samples:") + print(" decoder errors LER elapsed") + for summary in result.decoders: + print( + f" {summary.decoder:<36} {summary.logical_errors:6d} " + f"{summary.logical_error_rate:10.6f} {summary.elapsed_s:8.3f}s", + ) + + if result.pair_analysis is None: + return + print("Exact two-fault analysis:") + print(" decoder wrong_mass wrong_frac disagree_mass disagree_frac") + for summary in result.pair_analysis: + print( + f" {summary.decoder:<36} " + f"{summary.wrong_probability_mass:10.6f} " + f"{summary.wrong_probability_fraction:10.4f} " + f"{summary.disagree_tesseract_probability_mass:13.6f} " + f"{summary.disagree_tesseract_probability_fraction:13.4f}", + ) + + +def result_payload(result: CaseResult | dict[str, Any]) -> dict[str, Any]: + """Return a JSON-serializable case payload.""" + if isinstance(result, CaseResult): + return asdict(result) + return result + + +def write_results_json(path: Path, results: list[CaseResult | dict[str, Any]]) -> None: + """Write completed case results to JSON. + + Diagnostics can be expensive for larger distance/round combinations, so the + CLI writes after every finished case instead of only at process exit. + """ + payload = [result_payload(result) for result in results] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def expected_decoder_labels(args: argparse.Namespace) -> tuple[str, ...]: + """Return the decoder labels expected for this run configuration.""" + labels = [] if args.skip_tesseract else [f"native_raw_tesseract_b{beam}" for beam in args.tesseract_beams] + labels.extend(args.decoders) + return tuple(labels) + + +def case_key( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + decoder_labels: tuple[str, ...], + pair_analysis: bool, + pair_analysis_max_effects: int, +) -> tuple[Any, ...]: + """Build a strict cache key for one sampled diagnostic case.""" + return ( + RESULT_SCHEMA_VERSION, + distance, + rounds, + basis, + interaction_basis, + f"{p:.17g}", + shots, + seed, + decoder_labels, + pair_analysis, + pair_analysis_max_effects, + ) + + +def cached_case_key(payload: dict[str, Any]) -> tuple[Any, ...] | None: + """Return the cache key for a saved case, or ``None`` if it is incomplete.""" + try: + decoder_labels = tuple(decoder["decoder"] for decoder in payload["decoders"]) + return ( + payload["result_schema_version"], + payload["distance"], + payload["rounds"], + payload["basis"], + payload["interaction_basis"], + f"{float(payload['p']):.17g}", + payload["shots"], + payload["seed"], + decoder_labels, + payload["pair_analysis_requested"], + payload["pair_analysis_max_effects"], + ) + except KeyError: + return None + + +def load_cached_results(path: Path) -> tuple[list[dict[str, Any]], dict[tuple[Any, ...], dict[str, Any]]]: + """Load resumable diagnostic results from a previous JSON file.""" + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, list): + msg = f"Expected a list of case results in {path}" + raise TypeError(msg) + results = [item for item in payload if isinstance(item, dict)] + by_key = {key: result for result in results if (key := cached_case_key(result)) is not None} + return results, by_key + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X", "Z"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--p", nargs="+", type=float, default=[0.006]) + parser.add_argument("--shots", type=int, default=10000) + parser.add_argument("--seed", type=int, default=20260613) + parser.add_argument("--tesseract-beams", nargs="+", type=int, default=[5]) + parser.add_argument( + "--skip-tesseract", + action="store_true", + help="Skip raw-DEM Tesseract decoding for larger graphlike-only sampled comparisons.", + ) + parser.add_argument( + "--decoders", + nargs="+", + choices=GRAPHLIKE_DECODER_CHOICES, + default=GRAPHLIKE_DECODER_CHOICES, + help="Graphlike decoder variants to run in sampled comparisons.", + ) + parser.add_argument( + "--pair-analysis", + action="store_true", + help="Exhaustively compare decoders on all two-fault combinations when the effect count is small enough.", + ) + parser.add_argument("--pair-analysis-max-effects", type=int, default=400) + parser.add_argument("--save-json", type=Path, default=None) + parser.add_argument( + "--resume", + action="store_true", + help="Reuse matching completed cases from --save-json instead of recomputing them.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.resume and args.save_json is None: + msg = "--resume requires --save-json so completed cases have a source" + raise ValueError(msg) + + results: list[CaseResult | dict[str, Any]] = [] + cached_by_key: dict[tuple[Any, ...], dict[str, Any]] = {} + if args.resume and args.save_json is not None and args.save_json.exists(): + results, cached_by_key = load_cached_results(args.save_json) + print(f"Loaded {len(cached_by_key)} resumable cases from {args.save_json}", flush=True) + + decoder_labels = expected_decoder_labels(args) + total_cases = len(args.distances) * len(args.bases) * len(args.interaction_bases) * len(args.p) + case_index = 0 + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + for basis in args.bases: + for interaction_basis in args.interaction_bases: + for p in args.p: + case_index += 1 + label = ( + f"d={distance} r={rounds} basis={basis} " + f"basis2q={interaction_basis} p={p:g} shots={args.shots}" + ) + key = case_key( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=args.shots, + seed=args.seed, + decoder_labels=decoder_labels, + pair_analysis=args.pair_analysis, + pair_analysis_max_effects=args.pair_analysis_max_effects, + ) + if key in cached_by_key: + print(f"\n[{case_index}/{total_cases}] Reusing cached {label}", flush=True) + continue + + print(f"\n[{case_index}/{total_cases}] Starting {label}", flush=True) + start = time.perf_counter() + result = run_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=args.shots, + seed=args.seed, + tesseract_beams=[] if args.skip_tesseract else args.tesseract_beams, + decoder_names=set(args.decoders), + pair_analysis=args.pair_analysis, + pair_analysis_max_effects=args.pair_analysis_max_effects, + ) + results.append(result) + cached_by_key[key] = result_payload(result) + elapsed = time.perf_counter() - start + print(f"[{case_index}/{total_cases}] Finished {label} in {elapsed:.3f}s", flush=True) + print_case(result) + if args.save_json is not None: + write_results_json(args.save_json, results) + print(f"Wrote partial results to {args.save_json}", flush=True) + + if args.save_json is not None: + write_results_json(args.save_json, results) + print(f"\nWrote {args.save_json}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py new file mode 100644 index 000000000..9e90d979f --- /dev/null +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -0,0 +1,338 @@ +"""Benchmark graphlike DEM projections on fixed traced-QIS surface-code samples. + +This is a narrower companion to ``dem_decomposition_diagnostics.py``. It builds +each DEM view once, samples once from the exact native influence model, then +times correlated PyMatching construction and batch decoding for the selected +graphlike projections. Those graphlike projections are lossy decoder-facing +views of raw hyperedge mechanisms. +""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np +from dem_decomposition_diagnostics import ( + compare_raw_dems, + dem_stats, + terminal_graphlike_projection, + true_observable_flips, +) + +# SZZ/SZZdg surface diagnostics model Z-frame gates as virtual and p1-free. +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} + + +@dataclass(frozen=True) +class TimedValue: + label: str + elapsed_s: float + + +@dataclass(frozen=True) +class VariantResult: + variant: str + dem_stats: dict[str, Any] + dem_build_s: float + decoder_build_s: float + decode_s: float + logical_errors: int + logical_error_rate: float + + +@dataclass(frozen=True) +class BenchmarkResult: + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + shots: int + setup_timings: list[TimedValue] + raw_comparison: dict[str, Any] + variants: list[VariantResult] + + +def timed(label: str, callback: Any) -> tuple[Any, float]: + print(f"[start] {label}", flush=True) + start = time.perf_counter() + value = callback() + elapsed = time.perf_counter() - start + print(f"[done] {label}: {elapsed:.3f}s", flush=True) + return value, elapsed + + +def decode_with_correlated_pymatching( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, +) -> tuple[int, float, float]: + from pecos.decoders import PyMatchingDecoder + + decoder, decoder_build_s = timed( + "build correlated PyMatching", + lambda: PyMatchingDecoder.from_dem_with_correlations(dem_text, enable_correlations=True), + ) + + expected = true_observable_flips(observable_flips) + + def decode() -> list[list[int]]: + flat = detection_events.astype(np.uint8).flatten().tolist() + return decoder.decode_batch(flat, len(detection_events)) + + predictions, decode_s = timed("decode batch", decode) + predicted = np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + logical_errors = int(np.sum(predicted != expected)) + return logical_errors, decoder_build_s, decode_s + + +def build_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + variants: list[str], +) -> BenchmarkResult: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, + ) + from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, + ) + + print( + f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} p={p:g} shots={shots} ===", + flush=True, + ) + setup_timings: list[TimedValue] = [] + patch = SurfacePatch.create(distance=distance) + noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise_args = { + "p1": noise.p1, + "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, + "p2": noise.p2, + "p_meas": noise.p_meas, + "p_prep": noise.p_prep, + } + + tick_circuit, elapsed = timed( + "build traced-QIS tick circuit", + lambda: _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + setup_timings.append(TimedValue("build_traced_qis_tick_circuit", elapsed)) + normalize_traced_qis_tick_circuit(tick_circuit, context="graphlike DEM projection benchmark") + + native_raw, elapsed = timed( + "build native raw DEM", + lambda: generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + setup_timings.append(TimedValue("build_native_raw_dem", elapsed)) + + stim_raw, elapsed = timed( + "build Stim raw DEM", + lambda: generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + setup_timings.append(TimedValue("build_stim_raw_dem", elapsed)) + raw_comparison = asdict(compare_raw_dems(native_raw, stim_raw)) + print( + "raw native vs Stim: " + f"only_native={raw_comparison['only_native']} only_stim={raw_comparison['only_stim']} " + f"max_rel={raw_comparison['max_rel_probability_diff']:.3e}", + flush=True, + ) + if ( + raw_comparison["only_native"] == 0 + and raw_comparison["only_stim"] == 0 + and raw_comparison["max_rel_probability_diff"] > 0 + ): + print( + "raw structures match; max_rel is a probability-combination/rounding delta.", + flush=True, + ) + + sampler, elapsed = timed( + "build native influence sampler", + lambda: build_native_sampler( + patch, + rounds, + noise, + basis=basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + sampling_model="influence_dem", + ), + ) + setup_timings.append(TimedValue("build_native_influence_sampler", elapsed)) + + (detection_events, observable_flips), elapsed = timed( + "sample native influence events", + lambda: sampler.sample(num_shots=shots, seed=seed), + ) + setup_timings.append(TimedValue("sample_native_influence_events", elapsed)) + + def build_variant_dem(variant: str) -> str: + if variant == "native_source": + return generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="source_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + if variant == "native_terminal": + try: + return generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="terminal_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + except RuntimeError as exc: + if "terminal graphlike" not in str(exc): + raise + print("[info] using Python terminal graphlike projection fallback", flush=True) + return terminal_graphlike_projection(native_raw) + if variant == "stim": + return generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=True, + **noise_args, + ) + msg = f"unknown variant {variant!r}" + raise ValueError(msg) + + results: list[VariantResult] = [] + for variant in variants: + dem_text, dem_build_s = timed(f"build {variant} DEM", lambda variant=variant: build_variant_dem(variant)) + stats = asdict(dem_stats(dem_text)) + print( + f"{variant} DEM: errors={stats['error_lines']} sep={stats['separator_lines']} " + f"hyper={stats['hyperedge_lines']} max_line={stats['max_line_detectors']}", + flush=True, + ) + logical_errors, decoder_build_s, decode_s = decode_with_correlated_pymatching( + dem_text, + detection_events, + observable_flips, + ) + result = VariantResult( + variant=variant, + dem_stats=stats, + dem_build_s=dem_build_s, + decoder_build_s=decoder_build_s, + decode_s=decode_s, + logical_errors=logical_errors, + logical_error_rate=logical_errors / shots if shots else 0.0, + ) + print( + f"{variant}: errors={result.logical_errors} " + f"LER={result.logical_error_rate:.6f} " + f"build={result.dem_build_s:.3f}s " + f"matcher={result.decoder_build_s:.3f}s " + f"decode={result.decode_s:.3f}s", + flush=True, + ) + results.append(result) + + return BenchmarkResult( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=shots, + setup_timings=setup_timings, + raw_comparison=raw_comparison, + variants=results, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[7]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--p", type=float, default=0.006) + parser.add_argument("--shots", type=int, default=3000) + parser.add_argument("--seed", type=int, default=20260613) + parser.add_argument( + "--variants", + nargs="+", + choices=["native_source", "native_terminal", "stim"], + default=["native_terminal", "stim", "native_source"], + ) + parser.add_argument("--save-json", type=Path, default=None) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + results = [] + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + results.extend( + build_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=args.p, + shots=args.shots, + seed=args.seed, + variants=args.variants, + ) + for basis in args.bases + for interaction_basis in args.interaction_bases + ) + + if args.save_json is not None: + args.save_json.parent.mkdir(parents=True, exist_ok=True) + args.save_json.write_text( + json.dumps([asdict(result) for result in results], indent=2, sort_keys=True), + encoding="utf-8", + ) + print(f"\nWrote {args.save_json}", flush=True) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py new file mode 100644 index 000000000..9e6ba9a84 --- /dev/null +++ b/examples/surface/inner_decoder_study.py @@ -0,0 +1,554 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Rigorous inner-decoder study for ``LogicalSubgraphDecoder``. + +Answers, with statistics that can actually separate the candidates: + +* Fault tolerance / distance suppression -- does logical error rate (LER) fall + as code distance ``d`` grows below threshold, for each inner decoder? +* Threshold -- where do the per-distance LER curves cross (so above it more + distance hurts)? Estimated per inner. +* Lowest LER -- at fixed sub-threshold ``p``, which inner wins, and is the gap + statistically real (non-overlapping Jeffreys intervals)? +* Speed -- decoder build cost vs per-shot decode throughput, separated. + +Design choices that fix the under-powered earlier spot-check: + +* PAIRED comparison: one sampled batch per (family, d, p, seed) is decoded by + every inner, so decoder differences are not confounded by sampling noise. +* Sub-threshold ``p`` chosen so LER is large enough (~1e-3..1e-2) that 1e5 shots + yield hundreds of failures -> tight intervals that resolve 2x differences. +* Multiple seeds for the headline cells (batch-to-batch stability). +* Jeffreys (Bayesian Beta(k+1/2, n-k+1/2)) intervals -- the project's preferred + binomial CI -- computed via scipy as an analysis oracle (never a runtime dep). +* Build time (decoder construction) separated from decode time (decode_count). + +Results are appended as JSON lines to ``results/inner_decoder_study_.jsonl`` +so a run is resumable and analysable independently (see ``--phase analyze``). +""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem + +# Candidate inner decoders for the library default. fusion_blossom_serial is the +# current default (exact MWPM, bundled); pecos_uf:bp is the native option; +# belief_matching is BP+MWPM; pymatching/tesseract are external baselines. +CANDIDATES = [ + "fusion_blossom_serial", + "pecos_uf:bp", + "belief_matching", + "pymatching", + "tesseract", +] + +RESULTS_DIR = Path(__file__).resolve().parent / "results" + + +@dataclass(frozen=True) +class Cell: + """One measured (family, d, p, seed, inner) point.""" + + family: str + distance: int + rounds: int + p: float + seed: int + inner: str + num_shots: int + num_errors: int + ler: float + build_seconds: float + decode_seconds: float + + +# --------------------------------------------------------------------------- # +# Circuit families +# --------------------------------------------------------------------------- # + + +def _memory_builder(d: int, rounds: int) -> LogicalCircuitBuilder: + patch = SurfacePatch.create(distance=d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + return b + + +def _cx_builder(d: int, rounds: int) -> LogicalCircuitBuilder: + patch = SurfacePatch.create(distance=d) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], rounds, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], rounds, "Z") + return b + + +FAMILIES = {"memory": _memory_builder, "cx": _cx_builder} + + +# --------------------------------------------------------------------------- # +# Statistics (Jeffreys interval as an analysis oracle) +# --------------------------------------------------------------------------- # + + +def jeffreys_ci(k: int, n: int, alpha: float = 0.05) -> tuple[float, float]: + """Two-sided Jeffreys (Beta) credible interval for a binomial proportion. + + Posterior under the Jeffreys prior Beta(1/2, 1/2) is Beta(k+1/2, n-k+1/2). + Endpoints clamped to (0, 1) at k=0 / k=n per the standard convention. + """ + from scipy.stats import beta # analysis-only oracle, not a PECOS runtime dep + + lo = 0.0 if k == 0 else float(beta.ppf(alpha / 2.0, k + 0.5, n - k + 0.5)) + hi = 1.0 if k == n else float(beta.ppf(1.0 - alpha / 2.0, k + 0.5, n - k + 0.5)) + return lo, hi + + +def intervals_disjoint(a: Cell, b: Cell) -> bool: + """True if the two cells' Jeffreys 95% intervals do not overlap.""" + a_lo, a_hi = jeffreys_ci(a.num_errors, a.num_shots) + b_lo, b_hi = jeffreys_ci(b.num_errors, b.num_shots) + return a_hi < b_lo or b_hi < a_lo + + +# --------------------------------------------------------------------------- # +# Measurement +# --------------------------------------------------------------------------- # + + +def measure_cell( + family: str, + d: int, + rounds: int, + p: float, + seed: int, + inners: list[str], + n: int, + dem_source: str = "native", +) -> list[Cell]: + """Sample ONE batch and decode it with every inner (paired comparison). + + ``dem_source="native"`` uses the PECOS-native ``build_dem`` pipeline (the + main study). ``dem_source="stim"`` uses the exact DEM the production + default decode path consumes (``LogicalCircuitBuilder.build_decoder`` with + ``use_stim_dem=True``): the stim circuit's non-decomposed detector error + model. + """ + builder = FAMILIES[family](d, rounds) + if dem_source == "stim": + import stim # analysis-only here; the production path already requires it + + stim_str = builder.to_stim(p1=p, p2=p, p_meas=p) + dem = str(stim.Circuit(stim_str).detector_error_model(ignore_decomposition_failures=True)) + else: + dem = builder.build_dem(p1=p, p2=p, p_meas=p) + sc = builder.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + + cells: list[Cell] = [] + for inner in inners: + t0 = time.perf_counter() + dec = LogicalSubgraphDecoder(dem, sc, inner) + t1 = time.perf_counter() + wrong = dec.decode_count(batch) + t2 = time.perf_counter() + cells.append( + Cell( + family=family, + distance=d, + rounds=rounds, + p=p, + seed=seed, + inner=inner, + num_shots=n, + num_errors=wrong, + ler=wrong / n, + build_seconds=t1 - t0, + decode_seconds=t2 - t1, + ), + ) + return cells + + +def _append(path: Path, cells: list[Cell]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a") as fh: + for c in cells: + fh.write(json.dumps(asdict(c)) + "\n") + + +def _load(path: Path) -> list[Cell]: + if not path.exists(): + return [] + return [Cell(**json.loads(line)) for line in path.read_text().splitlines() if line.strip()] + + +# --------------------------------------------------------------------------- # +# Phases +# --------------------------------------------------------------------------- # + + +def run_suppress(path: Path) -> None: + """Distance suppression + decoder ranking with resolving statistics. + + Sub-threshold p, large n, multiple seeds; memory (1 obs) and transversal-CX + (multi-obs, where the earlier spot-check saw fusion beat bp).""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + plan = [ + ("memory", [3, 5, 7], [0.002, 0.003, 0.005], CANDIDATES, [1, 2, 3], 100_000), + ( + "cx", + [3, 5, 7], + [0.002, 0.003, 0.005], + ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"], + [1, 2, 3], + 50_000, + ), + ] + for family, ds, ps, inners, seeds, n in plan: + for d in ds: + for p in ps: + for seed in seeds: + todo = [i for i in inners if (family, d, p, seed, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell(family, d, d, p, seed, todo, n) + _append(path, cells) + best = min(cells, key=lambda c: c.ler) + print( + f"[suppress] {family:6s} d={d} p={p:.3f} seed={seed} n={n}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.num_errors}" for c in cells) + + f" best={best.inner.split(':')[0]} ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + + +def run_threshold(path: Path) -> None: + """Threshold sweep over p for the policy candidates -- locate the crossing.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pecos_uf:bp", "pymatching"] + ps = [0.004, 0.005, 0.006, 0.007, 0.008, 0.009, 0.010, 0.012] + for d in [3, 5, 7]: + for p in ps: + todo = [i for i in inners if ("memory", d, p, 1, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell("memory", d, d, p, 1, todo, 50_000) + _append(path, cells) + print( + f"[threshold] memory d={d} p={p:.3f}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.ler:.4f}" for c in cells) + + f" ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + + +def run_hyperedge(path: Path) -> None: + """Closes the graphlike-scope caveat: PECOS full DEMs are genuinely + non-graphlike (weight-8 hyperedges, ~70% of CX errors weight>=3), so test + whether hyperedge-aware inners beat plain MWPM on the per-observable-subgraph + path NEAR threshold (where the 2026-04-24 audit saw a 23% hyperedge effect on + the full memory DEM). If they merely tie, the LogicalSubgraphDecoder default + is optimal even in the hyperedge regime, not just on graphlike DEMs.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pymatching", "tesseract", "belief_matching", "belief_matching_correlated"] + plan = [ + ("memory", 5, [0.006, 0.008], [1, 2], 30_000), + ("memory", 7, [0.008], [1, 2], 20_000), + ("cx", 5, [0.004], [1, 2], 30_000), + ] + for family, d, ps, seeds, n in plan: + for p in ps: + for seed in seeds: + todo = [i for i in inners if (family, d, p, seed, i) not in done] + if not todo: + continue + cells = measure_cell(family, d, d, p, seed, todo, n) + _append(path, cells) + print( + f"[hyperedge] {family:6s} d={d} p={p:.3f} seed={seed}: " + + " ".join(f"{c.inner.split('_')[0][:6]}={c.num_errors}" for c in cells), + flush=True, + ) + + +def run_stim_spotcheck(path: Path) -> None: + """Confirm the ranking transfers to the DEM generator the shipped default decodes. + + The production default path (``LogicalCircuitBuilder.build_decoder``, + ``use_stim_dem=True``) consumes a STIM-generated DEM, while every main study + cell used the PECOS-native ``build_dem``. This cell repeats the memory + fusion-vs-bp contrast on the stim DEM (2026-06-11 review follow-up).""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pecos_uf:bp", "pymatching"] + p = 0.005 + for d in [3, 5]: + for seed in [1, 2, 3]: + todo = [i for i in inners if ("memory", d, p, seed, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell("memory", d, d, p, seed, todo, 100_000, dem_source="stim") + _append(path, cells) + print( + f"[stim_spotcheck] memory d={d} p={p:.3f} seed={seed}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.num_errors}" for c in cells) + + f" ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + # Pooled verdict for the d=5 contrast (the cell where bp is dominated on + # the native DEM): report Jeffreys intervals and disjointness. + agg = _pool(_load(path)) + for d in [3, 5]: + if ("memory", d, p, "fusion_blossom_serial") not in agg or ("memory", d, p, "pecos_uf:bp") not in agg: + continue + fk, fn = agg[("memory", d, p, "fusion_blossom_serial")] + rk, rn = agg[("memory", d, p, "pecos_uf:bp")] + flo, fhi = jeffreys_ci(fk, fn) + rlo, rhi = jeffreys_ci(rk, rn) + sep = "DISJOINT" if fhi < rlo or rhi < flo else "overlap" + ratio = (rk / rn) / (fk / fn) if fk else float("inf") + print( + f"[stim_spotcheck] pooled d={d}: fusion {fk}/{fn} [{flo:.2e},{fhi:.2e}] vs " + f"bp {rk}/{rn} [{rlo:.2e},{rhi:.2e}] -- {ratio:.2f}x, {sep}", + flush=True, + ) + + +def run_speed(path: Path) -> None: + """Per-shot decode throughput (build vs decode) at the costly d=7 point.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + for family in ["memory", "cx"]: + inners = CANDIDATES if family == "memory" else ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"] + todo = [i for i in inners if (family, 7, 0.003, 1, i) not in done] + if not todo: + continue + cells = measure_cell(family, 7, 7, 0.003, 1, todo, 50_000) + _append(path, cells) + for c in cells: + us = c.decode_seconds / c.num_shots * 1e6 + print( + f"[speed] {family:6s} d=7 {c.inner:24s}: build={c.build_seconds * 1e3:7.1f}ms " + f"decode={c.decode_seconds:6.2f}s {us:8.1f}us/shot", + flush=True, + ) + + +# --------------------------------------------------------------------------- # +# Analysis +# --------------------------------------------------------------------------- # + + +def _pool(cells: list[Cell]) -> dict: + """Pool repeated seeds for the same (family,d,p,inner) into one binomial.""" + agg: dict[tuple, list[int]] = {} + for c in cells: + key = (c.family, c.distance, c.p, c.inner) + k, n = agg.setdefault(key, [0, 0]) + agg[key] = [k + c.num_errors, n + c.num_shots] + return agg + + +def analyze(out_dir: Path) -> str: + lines: list[str] = [] + + def w(s: str = "") -> None: + lines.append(s) + + sup = _load(out_dir / "inner_decoder_study_suppress.jsonl") + thr = _load(out_dir / "inner_decoder_study_threshold.jsonl") + spd = _load(out_dir / "inner_decoder_study_speed.jsonl") + + w("# Inner-decoder study results") + w() + w("LER with Jeffreys 95% intervals (Beta(k+1/2, n-k+1/2)); seeds pooled into one") + w("binomial per (family, d, p, inner). `k/n` = failures / shots.") + w() + + if sup: + agg = _pool(sup) + families = sorted({k[0] for k in agg}) + inners = [i for i in CANDIDATES if any(k[3] == i for k in agg)] + for fam in families: + ps = sorted({k[2] for k in agg if k[0] == fam}) + ds = sorted({k[1] for k in agg if k[0] == fam}) + w(f"## {fam}: distance suppression + ranking") + w() + for p in ps: + w(f"### p = {p}") + w() + w("| inner | " + " | ".join(f"d={d}" for d in ds) + " |") + w("|---|" + "---|" * len(ds)) + for inner in inners: + cells = [] + for d in ds: + kv = agg.get((fam, d, p, inner)) + cells.append(kv) + row = [inner] + for kv in cells: + if kv is None: + row.append("--") + continue + k, n = kv + lo, hi = jeffreys_ci(k, n) + row.append(f"{k}/{n} {k / n:.2e} [{lo:.1e},{hi:.1e}]") + w("| " + " | ".join(row) + " |") + w() + # Decision-relevant contrast per distance: the best inner vs the + # native pecos_uf:bp candidate (the MWPM-family members are + # accuracy-tied on these graphlike DEMs, so best-vs-2nd is + # uninformative -- best-vs-bp is the contrast that picks a default). + for d in ds: + present = [(i, agg[(fam, d, p, i)]) for i in inners if (fam, d, p, i) in agg] + if len(present) < 2: + continue + present.sort(key=lambda t: t[1][0] / t[1][1]) + bi, (bk, bn) = present[0] + bench = "pecos_uf:bp" + if (fam, d, p, bench) not in agg or bi == bench: + continue + rk, rn = agg[(fam, d, p, bench)] + _blo, bhi = jeffreys_ci(bk, bn) + rlo, _rhi = jeffreys_ci(rk, rn) + sep = "DISJOINT" if bhi < rlo else "overlap" + ratio = (rk / rn) / (bk / bn) if bk else float("inf") + w( + f"- d={d}: best **{bi}** {bk / bn:.2e} vs {bench} {rk / rn:.2e} " + f"({ratio:.1f}x) -- Jeffreys intervals {sep}", + ) + w() + # Suppression check + exponent per inner (pooled across seeds). + w(f"### {fam}: suppression exponent (LER ~ (p/p_th)^((d+1)/2))") + w() + for p in ps: + for inner in inners: + seq = [(d, agg[(fam, d, p, inner)]) for d in ds if (fam, d, p, inner) in agg] + seq = [(d, kv) for d, kv in seq if kv[0] > 0] # need nonzero to log + if len(seq) < 2: + continue + suppresses = all( + seq[i + 1][1][0] / seq[i + 1][1][1] < seq[i][1][0] / seq[i][1][1] for i in range(len(seq) - 1) + ) + ratios = [ + (seq[i][1][0] / seq[i][1][1]) / (seq[i + 1][1][0] / seq[i + 1][1][1]) + for i in range(len(seq) - 1) + ] + tag = "suppresses" if suppresses else "NOT monotone" + w(f"- p={p} {inner}: {tag}; per-step LER ratio " + ", ".join(f"{r:.1f}x" for r in ratios)) + w() + + if thr: + agg = _pool(thr) + inners = sorted({k[3] for k in agg}) + ds = sorted({k[1] for k in agg}) + ps = sorted({k[2] for k in agg}) + w("## memory: threshold crossing") + w() + for inner in inners: + w(f"### {inner}") + w() + w("| p | " + " | ".join(f"d={d}" for d in ds) + " |") + w("|---|" + "---|" * len(ds)) + for p in ps: + row = [f"{p:.3f}"] + for d in ds: + kv = agg.get(("memory", d, p, inner)) + row.append(f"{kv[0] / kv[1]:.2e}" if kv else "--") + w("| " + " | ".join(row) + " |") + # crossing estimate: smallest p where d=max no longer beats d=min + cross = None + d_lo, d_hi = ds[0], ds[-1] + for p in ps: + a = agg.get(("memory", d_lo, p, inner)) + b = agg.get(("memory", d_hi, p, inner)) + if a and b and b[0] / b[1] >= a[0] / a[1]: + cross = p + break + w() + w( + f"- threshold estimate (d={d_hi} stops beating d={d_lo}): " + + (f"~{cross}" if cross else f"above {ps[-1]} (not reached)"), + ) + w() + + if spd: + w("## speed (d=7, p=0.003, n per cell as sampled)") + w() + w("| family | inner | build ms | decode s | us/shot |") + w("|---|---|---:|---:|---:|") + for c in sorted(spd, key=lambda c: (c.family, c.decode_seconds)): + us = c.decode_seconds / c.num_shots * 1e6 + w(f"| {c.family} | {c.inner} | {c.build_seconds * 1e3:.1f} | {c.decode_seconds:.2f} | {us:.1f} |") + w() + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- # + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--phase", + required=True, + choices=["suppress", "threshold", "hyperedge", "speed", "stim_spotcheck", "analyze", "smoke"], + ) + ap.add_argument("--out", type=Path, default=RESULTS_DIR) + args = ap.parse_args() + + if args.phase == "smoke": + cells = measure_cell("memory", 3, 3, 0.005, 1, ["fusion_blossom_serial", "pecos_uf:bp"], 2000) + for c in cells: + lo, hi = jeffreys_ci(c.num_errors, c.num_shots) + print( + f"smoke {c.inner}: {c.num_errors}/{c.num_shots} ler={c.ler:.4f} " + f"CI=[{lo:.4f},{hi:.4f}] build={c.build_seconds * 1e3:.1f}ms decode={c.decode_seconds:.3f}s", + ) + cx = measure_cell("cx", 3, 3, 0.005, 1, ["fusion_blossom_serial"], 2000) + print(f"smoke cx: {cx[0].num_errors}/{cx[0].num_shots} ler={cx[0].ler:.4f}") + return + + if args.phase == "analyze": + report = analyze(args.out) + print(report) + (args.out / "inner_decoder_study_report.md").write_text(report + "\n") + print(f"\n[written] {args.out / 'inner_decoder_study_report.md'}") + return + + path = args.out / f"inner_decoder_study_{args.phase}.jsonl" + { + "suppress": run_suppress, + "threshold": run_threshold, + "hyperedge": run_hyperedge, + "speed": run_speed, + "stim_spotcheck": run_stim_spotcheck, + }[args.phase](path) + print(f"[done] {args.phase} -> {path}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 4839a9ad3..9ce2b9124 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -8,7 +8,8 @@ - direct ``selene_sim`` execution with either Selene ``Stim`` or the PECOS Selene stabilizer plugin - optional native DEM sampling via ``build_native_sampler(...)`` -- a depolarizing noise model with ``p2 = p``, ``p1 = p/30``, ``p_meas = p_prep = p/3`` +- circuit-level rates ``p2 = p``, ``p1 = p/30``, ``p_meas = p_prep = p/3`` + with selectable ``sim`` runtime noise builders - ``SurfaceDecoder(...)`` with PECOS-native DEMs (PyMatching or Tesseract) For the ``sim`` backend, decoding is performed relative to a cached noiseless @@ -261,7 +262,7 @@ def _backend_runtime_label(sample_backend: str, native_circuit_source: str = "ab if sample_backend == "sim": return ( "sim(Guppy(...)).classical(selene_engine()).quantum(pecos.stabilizer()) " - f"+ PECOS depolarizing noise + native DEM source={native_circuit_source} + noiseless " + f"+ PECOS runtime noise + native DEM source={native_circuit_source} + noiseless " "reference-trajectory calibration" ) if sample_backend == "selene_sim": @@ -552,29 +553,37 @@ def _noise_model_description(args: argparse.Namespace) -> str: p1s = getattr(args, "p1_scale", 1.0 / 30.0) pms = getattr(args, "p_meas_scale", 1.0 / 3.0) pps = getattr(args, "p_prep_scale", 1.0 / 3.0) - return f"depolarizing with p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" + sim_noise_model = getattr(args, "sim_noise_model", "depolarizing") + base = f"p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" + if sim_noise_model == "general": + return f"general_noise runtime ({base}, leak2depolar=True, p_idle_coherent=False)" + return f"depolarizing runtime ({base})" def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int = 5) -> object: """Create a DEM-level decoder from a DEM string. - Supports MWPM decoders (pymatching), search decoders (tesseract), and - check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) - via DemAwareDecoder which extracts the check matrix from the DEM. + Supports MWPM decoders (pymatching, pymatching_correlated, + pymatching_uncorrelated), search decoders (tesseract), and check-matrix + decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) via + DemAwareDecoder which extracts the check matrix from the DEM. """ if decoder_type == "tesseract": - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder dem_filtered = "\n".join(line for line in dem_str.split("\n") if not line.startswith("logical_observable")) return TesseractDecoder.from_dem(dem_filtered, preset="fast", det_beam=tesseract_beam) if decoder_type in _CHECK_MATRIX_DECODERS: - from pecos_rslib.decoders import DemAwareDecoder + from pecos.decoders import DemAwareDecoder dem_filtered = "\n".join(line for line in dem_str.split("\n") if not line.startswith("logical_observable")) return DemAwareDecoder.from_dem(dem_filtered, decoder_type=decoder_type) - from pecos_rslib.decoders import PyMatchingDecoder + from pecos.decoders import PyMatchingDecoder + + if decoder_type in {"pymatching", "pymatching_correlated"}: + return PyMatchingDecoder.from_dem_with_correlations(dem_str, enable_correlations=True) return PyMatchingDecoder.from_dem(dem_str) @@ -613,7 +622,7 @@ def _decode_all_shots( ) # PyMatching batch: takes flattened (num_shots * num_detectors) u8 array - from pecos_rslib.decoders import PyMatchingDecoder + from pecos.decoders import PyMatchingDecoder if isinstance(dem_decoder, PyMatchingDecoder): flat = detection_events.astype(np.uint8).flatten().tolist() @@ -623,7 +632,7 @@ def _decode_all_shots( return int(np.sum(predicted != true_flips)) # Tesseract batch: takes list of syndromes, parallel rayon - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder if isinstance(dem_decoder, TesseractDecoder): syndromes = [detection_events[i].astype(np.uint8).tolist() for i in range(num_shots)] @@ -652,6 +661,7 @@ def _decoder_runtime( physical_error_rate: float, dem_mode: str, native_circuit_source: str, + interaction_basis: str = "cx", decoder_type: str = "pymatching", ancilla_budget: int | None = None, p1_scale: float = 0.1, @@ -678,6 +688,7 @@ def _decoder_runtime( circuit_level_dem_mode=dem_mode, circuit_level_dem_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) return _DecoderRuntime( patch=patch, @@ -689,6 +700,13 @@ def _decoder_runtime( ) +def _native_sampler_model_for_decoder(decoder_type: str) -> str: + """Choose the native sampler model paired with a DEM decoder.""" + if decoder_type in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: + return "dem" + return "influence_dem" + + @cache def _native_sampler_runtime( distance: int, @@ -697,6 +715,7 @@ def _native_sampler_runtime( physical_error_rate: float, dem_mode: str, native_circuit_source: str, + interaction_basis: str = "cx", decoder_type: str = "pymatching", ancilla_budget: int | None = None, p1_scale: float = 0.1, @@ -714,6 +733,7 @@ def _native_sampler_runtime( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -727,10 +747,14 @@ def _native_sampler_runtime( basis=basis, circuit_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + sampling_model=_native_sampler_model_for_decoder(decoder_type), ) - # PyMatching needs decomposed (graph-like) DEMs; Tesseract and check-matrix - # decoders handle hyperedges natively and should get the full DEM. - if decoder_type == "pymatching": + # PyMatching uses graphlike decomposed DEMs. The production default and the + # explicit correlated alias consume decomposition separators as correlation + # metadata. Tesseract and check-matrix decoders handle hyperedges natively + # and should get the full DEM. + if decoder_type in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: dem_str = runtime.decoder.get_dem(basis.upper(), circuit_level=True) else: dem_str = generate_circuit_level_dem_from_builder( @@ -741,6 +765,7 @@ def _native_sampler_runtime( decompose_errors=False, circuit_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) dem_decoder = _create_dem_decoder(decoder_type, dem_str) # The traced-QIS sampler stack has a noticeable one-time initialization cost @@ -764,7 +789,9 @@ def _sim_reference_trajectory( distance: int, total_rounds: int, basis: str, -) -> tuple[tuple[tuple[int, ...], ...], tuple[tuple[int, ...], ...], tuple[int, ...]]: + interaction_basis: str, + sim_noise_model: str, +) -> tuple[tuple[tuple[int, ...], ...], tuple[tuple[int, ...], ...], tuple[int, ...], tuple[int, ...]]: """Cache a noiseless gate-level trajectory used as a decoding reference.""" import numpy as np from pecos.qec.surface import SurfacePatch @@ -778,6 +805,8 @@ def _sim_reference_trajectory( total_rounds=total_rounds, num_shots=1, seed=0, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) synx_rows = _reshape_round_values( @@ -793,32 +822,40 @@ def _sim_reference_trajectory( "synz", ) final = np.asarray(_result_rows_for_key(result_dict, "final")[0], dtype=np.uint8) + init_key = "init_synx" if basis.upper() == "Z" else "init_synz" + init = np.asarray(_result_rows_for_key(result_dict, init_key)[0], dtype=np.uint8) return ( tuple(tuple(int(v) for v in row) for row in synx_rows), tuple(tuple(int(v) for v in row) for row in synz_rows), tuple(int(v) for v in final.tolist()), + tuple(int(v) for v in init.tolist()), ) @cache -def _compiled_guppy_hugr(distance: int, total_rounds: int, basis: str) -> bytes: +def _compiled_guppy_hugr(distance: int, total_rounds: int, basis: str, interaction_basis: str = "cx") -> bytes: """Cache compiled HUGR bytes for the direct selene_sim backend.""" from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy import make_surface_code - program = make_surface_code(distance=distance, num_rounds=total_rounds, basis=basis) + program = make_surface_code( + distance=distance, + num_rounds=total_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) return compile_guppy_to_hugr(program) @cache -def _selene_instance(distance: int, total_rounds: int, basis: str) -> object: +def _selene_instance(distance: int, total_rounds: int, basis: str, interaction_basis: str = "cx") -> object: """Cache a built Selene instance for one circuit shape.""" from selene_sim import build instance = build( - _compiled_guppy_hugr(distance, total_rounds, basis), - name=f"surface_d{distance}_{basis.lower()}_r{total_rounds}", + _compiled_guppy_hugr(distance, total_rounds, basis, interaction_basis), + name=f"surface_d{distance}_{basis.lower()}_{interaction_basis}_r{total_rounds}", ) _CACHED_SELENE_INSTANCES.append(instance) return instance @@ -837,6 +874,8 @@ def _run_gate_backend_result_dict( p1_scale: float = 0.1, p_meas_scale: float = 0.5, p_prep_scale: float = 0.5, + interaction_basis: str = "cx", + sim_noise_model: str = "depolarizing", ) -> dict[str, list[list[int]]]: """Run one gate-level backend and normalize results to a shot-map-like dict.""" import os @@ -860,11 +899,11 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] ) compile_start = time.perf_counter() - _compiled_guppy_hugr(distance, total_rounds, basis) + _compiled_guppy_hugr(distance, total_rounds, basis, interaction_basis) compile_seconds = time.perf_counter() - compile_start build_start = time.perf_counter() - instance = _selene_instance(distance, total_rounds, basis) + instance = _selene_instance(distance, total_rounds, basis, interaction_basis) build_seconds = time.perf_counter() - build_start reset_start = time.perf_counter() @@ -885,7 +924,7 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] run_start = time.perf_counter() for shot_results in instance.run_shots( simulator=simulator, - n_qubits=get_num_qubits(distance), + n_qubits=get_num_qubits(distance, interaction_basis=interaction_basis), n_shots=num_shots, error_model=error_model, runtime=SimpleRuntime(), @@ -914,24 +953,45 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] if sample_backend == "sim": backend_start = time.perf_counter() noise_start = time.perf_counter() - noise_model = pecos.depolarizing_noise() - noise_model.set_probabilities( - physical_error_rate * p_prep_scale, # p_prep - physical_error_rate * p_meas_scale, # p_meas_0 - physical_error_rate * p_meas_scale, # p_meas_1 - physical_error_rate * p1_scale, # p1 (single-qubit gates) - physical_error_rate, # p2 (two-qubit gates) - ) + if sim_noise_model == "general": + use_coherent_idle = False + noise_model = ( + pecos.general_noise() + .with_prep_probability(physical_error_rate * p_prep_scale) + .with_meas_probability(physical_error_rate * p_meas_scale) + .with_p1_probability(physical_error_rate * p1_scale) + .with_p2_probability(physical_error_rate) + .with_leakage_scale(0.0) + .with_p_idle_coherent(use_coherent_idle) + .with_seed(seed) + ) + elif sim_noise_model == "depolarizing": + noise_model = pecos.depolarizing_noise() + noise_model.set_probabilities( + physical_error_rate * p_prep_scale, # p_prep + physical_error_rate * p_meas_scale, # p_meas_0 + physical_error_rate * p_meas_scale, # p_meas_1 + physical_error_rate * p1_scale, # p1 (single-qubit gates) + physical_error_rate, # p2 (two-qubit gates) + ) + else: + msg = f"Unknown sim noise model: {sim_noise_model}" + raise ValueError(msg) noise_seconds = time.perf_counter() - noise_start program_start = time.perf_counter() - program = make_surface_code(distance=distance, num_rounds=total_rounds, basis=basis) + program = make_surface_code( + distance=distance, + num_rounds=total_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) program_seconds = time.perf_counter() - program_start run_start = time.perf_counter() shot_vec = ( pecos.sim(program) .classical(pecos.selene_engine()) .quantum(pecos.stabilizer()) - .qubits(get_num_qubits(distance)) + .qubits(get_num_qubits(distance, interaction_basis=interaction_basis)) .noise(noise_model) .seed(seed) .run(num_shots) @@ -979,6 +1039,8 @@ def _profile_gate_backends( duration_rounds_by_distance: dict[int, tuple[int, ...]], shots: int, seed: int, + interaction_basis: str, + sim_noise_model: str, warmup_repetitions: int, benchmark_repetitions: int, ) -> None: @@ -1045,6 +1107,8 @@ def _profile_gate_backends( total_rounds=total_rounds, num_shots=shots, seed=combo_seed + rep, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) runs: list[dict[str, float]] = [] @@ -1059,6 +1123,8 @@ def _profile_gate_backends( num_shots=shots, seed=combo_seed + warmup_repetitions + rep, timing_sink=timing, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) runs.append(timing) @@ -1102,6 +1168,8 @@ def _run_memory_point( p1_scale: float = 0.1, p_meas_scale: float = 0.5, p_prep_scale: float = 0.5, + interaction_basis: str = "cx", + sim_noise_model: str = "depolarizing", ) -> SweepPoint: """Run one surface-memory point and decode it with native PECOS DEMs.""" import numpy as np @@ -1114,6 +1182,7 @@ def _run_memory_point( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -1130,15 +1199,18 @@ def _run_memory_point( num_raw_errors: int | None = 0 if sample_backend in {"sim", "selene_sim", "selene_stabilizer_plugin"}: - ref_synx_rows, ref_synz_rows, ref_final_row = _sim_reference_trajectory( + ref_synx_rows, ref_synz_rows, ref_final_row, ref_init_row = _sim_reference_trajectory( sample_backend, distance, total_rounds, basis.upper(), + interaction_basis, + sim_noise_model, ) ref_synx_list = [np.asarray(row, dtype=np.uint8) for row in ref_synx_rows] ref_synz_list = [np.asarray(row, dtype=np.uint8) for row in ref_synz_rows] ref_final = np.asarray(ref_final_row, dtype=np.uint8) + ref_init = np.asarray(ref_init_row, dtype=np.uint8) result_dict = _run_gate_backend_result_dict( sample_backend=sample_backend, distance=distance, @@ -1150,16 +1222,26 @@ def _run_memory_point( p1_scale=p1_scale, p_meas_scale=p_meas_scale, p_prep_scale=p_prep_scale, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) synx_rows = _result_rows_for_key(result_dict, "synx") synz_rows = _result_rows_for_key(result_dict, "synz") final_rows = _result_rows_for_key(result_dict, "final") - - if len(synx_rows) != num_shots or len(synz_rows) != num_shots or len(final_rows) != num_shots: + init_key = "init_synx" if basis.upper() == "Z" else "init_synz" + init_rows = _result_rows_for_key(result_dict, init_key) + + if ( + len(synx_rows) != num_shots + or len(synz_rows) != num_shots + or len(final_rows) != num_shots + or len(init_rows) != num_shots + ): msg = ( "Result register lengths do not match the requested shot count: " - f"synx={len(synx_rows)}, synz={len(synz_rows)}, final={len(final_rows)}, shots={num_shots}" + f"synx={len(synx_rows)}, synz={len(synz_rows)}, final={len(final_rows)}, " + f"{init_key}={len(init_rows)}, shots={num_shots}" ) raise ValueError( msg, @@ -1169,12 +1251,16 @@ def _run_memory_point( synx_list = _reshape_round_values(synx_rows[shot_idx], total_rounds, num_x_stab, "synx") synz_list = _reshape_round_values(synz_rows[shot_idx], total_rounds, num_z_stab, "synz") final = np.asarray(final_rows[shot_idx], dtype=np.uint8) + init = np.asarray(init_rows[shot_idx], dtype=np.uint8) if final.size != patch.geometry.num_data: msg = f"Register 'final' has {final.size} bits for one shot, expected {patch.geometry.num_data}" raise ValueError( msg, ) + if init.shape != ref_init.shape: + msg = f"Register {init_key!r} has shape {init.shape}, expected {ref_init.shape}" + raise ValueError(msg) # Decode relative to the noiseless gate-level baseline so the native # DEM sees deviations from the actual circuit trajectory. @@ -1187,6 +1273,7 @@ def _run_memory_point( for synz, ref_synz in zip(synz_list, ref_synz_list, strict=True) ] final = final ^ ref_final + init = init ^ ref_init raw_parity = int(sum(int(final[q]) for q in logical_qubits) % 2) if num_raw_errors is None: @@ -1195,9 +1282,9 @@ def _run_memory_point( num_raw_errors += raw_parity if basis.upper() == "Z": - is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final) + is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final, init_synx=init) else: - is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final) + is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final, init_synz=init) num_logical_errors += int(is_error) elif sample_backend == "native_sampler": native_runtime = _native_sampler_runtime( @@ -1207,6 +1294,7 @@ def _run_memory_point( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -1215,27 +1303,28 @@ def _run_memory_point( ) sampler = native_runtime.sampler dem_decoder = native_runtime.dem_decoder - detection_events, observable_flips = sampler.sample(num_shots=num_shots, seed=seed) num_raw_errors = None # Fast path: sample+decode entirely in Rust via ObservableDecoder trait. # The DemSampler keeps all per-shot data in Rust -- nothing crosses to Python. dem_str_for_rust = native_runtime.dem_str rust_sampler = getattr(sampler, "sampler", None) - if dem_str_for_rust and rust_sampler and hasattr(rust_sampler, "sample_decode_count"): + rust_decoder_type = "pymatching" if decoder_type == "pymatching_correlated" else decoder_type + use_rust_sample_decode = dem_str_for_rust and rust_sampler and hasattr(rust_sampler, "sample_decode_count") + if use_rust_sample_decode: # Use parallel path for slow decoders (Tesseract, BP+OSD, etc.) - if decoder_type != "pymatching" and hasattr(rust_sampler, "sample_decode_count_parallel"): + if rust_decoder_type != "pymatching" and hasattr(rust_sampler, "sample_decode_count_parallel"): num_logical_errors = rust_sampler.sample_decode_count_parallel( dem_str_for_rust, num_shots, - decoder_type, + rust_decoder_type, seed, ) else: num_logical_errors = rust_sampler.sample_decode_count( dem_str_for_rust, num_shots, - decoder_type, + rust_decoder_type, seed, ) else: @@ -1874,6 +1963,8 @@ def _write_json_results( "shots": args.shots, "dem_mode": args.dem_mode, "native_circuit_source": args.native_circuit_source, + "interaction_basis": args.interaction_basis, + "sim_noise_model": args.sim_noise_model, "seed": args.seed, "backend_runtime_descriptions": { backend: _backend_runtime_label(backend, args.native_circuit_source) @@ -3511,6 +3602,8 @@ def _config_for_report(args: argparse.Namespace) -> dict[str, Any]: # appendix page can read the same field from either source. "sample_backend_mode": getattr(args, "sample_backend", None), "native_circuit_source": getattr(args, "native_circuit_source", None), + "interaction_basis": getattr(args, "interaction_basis", None), + "sim_noise_model": getattr(args, "sim_noise_model", None), "decoder": getattr(args, "decoder", ["pymatching"]), "noise_model": _noise_model_description(args), "seed": getattr(args, "seed", None), @@ -3621,20 +3714,53 @@ def _parse_args() -> argparse.Namespace: "matching the standard circuit-level noise model from the QEC literature." ), ) + parser.add_argument( + "--interaction-basis", + choices=["cx", "szz"], + default="cx", + help="Surface-memory two-qubit interaction basis to generate and analyze.", + ) + parser.add_argument( + "--sim-noise-model", + choices=["depolarizing", "general"], + default="depolarizing", + help=( + "Runtime noise model used by --sample-backend sim. The 'general' " + "option sets leak2depolar=True and p_idle_coherent=False." + ), + ) parser.add_argument( "--dem-mode", - choices=["native_decomposed", "native_full"], + choices=["native_decomposed", "native_full", "native_terminal_graphlike"], default="native_decomposed", - help="PECOS native DEM mode. PyMatching typically wants native_decomposed.", + help=( + "PECOS native DEM mode. Graph decoders such as PyMatching require " + "native_decomposed or native_terminal_graphlike; raw-DEM decoders " + "should use native_full. The graphlike modes are lossy " + "hyperedge-to-edge decoder projections." + ), ) parser.add_argument( "--decoder", nargs="+", - choices=["pymatching", "tesseract", "bp_osd", "bp_lsd", "union_find", "relay_bp", "min_sum_bp"], + choices=[ + "pymatching", + "pymatching_correlated", + "pymatching_uncorrelated", + "tesseract", + "bp_osd", + "bp_lsd", + "union_find", + "relay_bp", + "min_sum_bp", + ], default=["pymatching"], help=( "Decoder(s) for circuit-level DEM decoding. Specify multiple to " "compare them side-by-side in plots and reports. Default: pymatching. " + "pymatching and pymatching_correlated enable PyMatching's DEM-correlation " + "mode for decomposed errors; pymatching_uncorrelated keeps the plain " + "graphlike baseline for A/B diagnostics. " "Check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) " "extract a check matrix from the DEM automatically." ), @@ -3845,16 +3971,15 @@ def _print_config_banner( print(f"shots / point : {args.shots}") print(f"sample backend mode: {args.sample_backend}") print(f"executed backends: {backends}") + print(f"interaction basis: {args.interaction_basis}") + print(f"sim noise model : {args.sim_noise_model}") print(f"DEM mode : {args.dem_mode}") print(f"native circuit source: {args.native_circuit_source}") decoders = getattr(args, "decoder", ["pymatching"]) print(f"decoder(s) : {', '.join(decoders)} via SurfaceDecoder(native PECOS DEM)") for backend in backends: print(f"runtime[{backend}] : {_backend_runtime_label(backend, args.native_circuit_source)}") - p1s = getattr(args, "p1_scale", 0.1) - pms = getattr(args, "p_meas_scale", 0.5) - pps = getattr(args, "p_prep_scale", 0.5) - print(f"noise model : depolarizing with p1={p1s}*p, p2=p, p_meas={pms}*p, p_prep={pps}*p") + print(f"noise model : {_noise_model_description(args)}") print("fit model : p_L(r) = 0.5 * (1 - (1 - 2 * epsilon) ** r)") if output_dir is not None: print(f"artifact dir : {output_dir}") @@ -3902,6 +4027,8 @@ def _run_one_memory_point( p1_scale=getattr(args, "p1_scale", 0.1), p_meas_scale=getattr(args, "p_meas_scale", 0.5), p_prep_scale=getattr(args, "p_prep_scale", 0.5), + interaction_basis=getattr(args, "interaction_basis", "cx"), + sim_noise_model=getattr(args, "sim_noise_model", "depolarizing"), ) elapsed_seconds = time.perf_counter() - point_start naive_per_round = ler_per_round_exp(point.logical_error_rate, point.total_rounds) @@ -3919,6 +4046,7 @@ def _run_one_memory_point( "physical_error_rate": physical_error_rate, "total_rounds": total_rounds, "num_shots": args.shots, + "interaction_basis": getattr(args, "interaction_basis", "cx"), "elapsed_seconds": elapsed_seconds, } return point, timing_row @@ -4194,6 +4322,9 @@ def _print_post_sweep_analysis( def main() -> int: """Run the threshold sweep CLI and optionally write summary artifacts.""" args = _parse_args() + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + args.interaction_basis = _normalize_interaction_basis(args.interaction_basis) if args.open_html: args.save_html = True if args.save_html: @@ -4238,6 +4369,8 @@ def main() -> int: duration_rounds_by_distance=duration_rounds_by_distance, shots=args.shots, seed=args.seed, + interaction_basis=args.interaction_basis, + sim_noise_model=args.sim_noise_model, warmup_repetitions=args.benchmark_warmup, benchmark_repetitions=args.benchmark_repetitions, ) diff --git a/examples/surface/results/inner_decoder_study_hyperedge.jsonl b/examples/surface/results/inner_decoder_study_hyperedge.jsonl new file mode 100644 index 000000000..d8b94fe04 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_hyperedge.jsonl @@ -0,0 +1,40 @@ +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.003566185012459755, "decode_seconds": 3.37455288390629} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0038856130558997393, "decode_seconds": 0.6466185129247606} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.005911809974350035, "decode_seconds": 15.393886688980274} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0071361170848831534, "decode_seconds": 6.31418666895479} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0071890190010890365, "decode_seconds": 6.367211817996576} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.0032669759821146727, "decode_seconds": 3.3683419070439413} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.0037438629660755396, "decode_seconds": 0.6292870710603893} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.005560801015235484, "decode_seconds": 14.951133309979923} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.006299200002104044, "decode_seconds": 6.250429176958278} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.00739661802072078, "decode_seconds": 6.481872584903613} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.0032418479677289724, "decode_seconds": 4.569911486003548} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.003733533900231123, "decode_seconds": 0.7791196580510587} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.00560479296837002, "decode_seconds": 19.93792283802759} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.0068061730125918984, "decode_seconds": 7.923268544021994} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.007016985095106065, "decode_seconds": 7.727108254912309} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.0034840760054066777, "decode_seconds": 4.617438982008025} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 588, "ler": 0.0196, "build_seconds": 0.003798080957494676, "decode_seconds": 0.7727671850007027} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 589, "ler": 0.019633333333333332, "build_seconds": 0.005618234979920089, "decode_seconds": 20.43235256697517} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.006501637981273234, "decode_seconds": 8.125532574020326} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.007528997026383877, "decode_seconds": 8.493251777952537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.012017961009405553, "decode_seconds": 13.148979397956282} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 20000, "num_errors": 374, "ler": 0.0187, "build_seconds": 0.01236077700741589, "decode_seconds": 1.443247208953835} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "tesseract", "num_shots": 20000, "num_errors": 377, "ler": 0.01885, "build_seconds": 0.017561979009769857, "decode_seconds": 58.6753382619936} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "belief_matching", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.030035778996534646, "decode_seconds": 20.313708811998367} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.03070830798242241, "decode_seconds": 20.43584023998119} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.011728838086128235, "decode_seconds": 12.78964776895009} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "pymatching", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.012101199012249708, "decode_seconds": 1.4187101080315188} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "tesseract", "num_shots": 20000, "num_errors": 335, "ler": 0.01675, "build_seconds": 0.01718469092156738, "decode_seconds": 55.972942068008706} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "belief_matching", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.029899114975705743, "decode_seconds": 20.612918598926626} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.03118286095559597, "decode_seconds": 20.239125563995913} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.019938747980631888, "decode_seconds": 14.27238380908966} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.01992121199145913, "decode_seconds": 2.0521467130165547} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.030081987963058054, "decode_seconds": 58.16961134807207} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.047197493026033044, "decode_seconds": 30.490634263958782} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.04685803898610175, "decode_seconds": 30.193152333027683} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.018612609012052417, "decode_seconds": 13.599516084999777} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.01944837300106883, "decode_seconds": 1.9902514149434865} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 439, "ler": 0.014633333333333333, "build_seconds": 0.029277449008077383, "decode_seconds": 58.933918961905874} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.04867564095184207, "decode_seconds": 30.084009875077754} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.04650826100260019, "decode_seconds": 30.7477054920746} diff --git a/examples/surface/results/inner_decoder_study_report.md b/examples/surface/results/inner_decoder_study_report.md new file mode 100644 index 000000000..82f40a73d --- /dev/null +++ b/examples/surface/results/inner_decoder_study_report.md @@ -0,0 +1,182 @@ +# Inner-decoder study results + +LER with Jeffreys 95% intervals (Beta(k+1/2, n-k+1/2)); seeds pooled into one +binomial per (family, d, p, inner). `k/n` = failures / shots. + +## cx: distance suppression + ranking + +### p = 0.002 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 1039/150000 6.93e-03 [6.5e-03,7.4e-03] | 294/150000 1.96e-03 [1.7e-03,2.2e-03] | 59/150000 3.93e-04 [3.0e-04,5.0e-04] | +| pecos_uf:bp | 1069/150000 7.13e-03 [6.7e-03,7.6e-03] | 556/150000 3.71e-03 [3.4e-03,4.0e-03] | 159/150000 1.06e-03 [9.0e-04,1.2e-03] | +| belief_matching | 1042/150000 6.95e-03 [6.5e-03,7.4e-03] | 294/150000 1.96e-03 [1.7e-03,2.2e-03] | 59/150000 3.93e-04 [3.0e-04,5.0e-04] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 6.93e-03 vs pecos_uf:bp 7.13e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 1.96e-03 vs pecos_uf:bp 3.71e-03 (1.9x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 3.93e-04 vs pecos_uf:bp 1.06e-03 (2.7x) -- Jeffreys intervals DISJOINT + +### p = 0.003 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 2158/150000 1.44e-02 [1.4e-02,1.5e-02] | 1010/150000 6.73e-03 [6.3e-03,7.2e-03] | 333/150000 2.22e-03 [2.0e-03,2.5e-03] | +| pecos_uf:bp | 2217/150000 1.48e-02 [1.4e-02,1.5e-02] | 1641/150000 1.09e-02 [1.0e-02,1.1e-02] | 662/150000 4.41e-03 [4.1e-03,4.8e-03] | +| belief_matching | 2159/150000 1.44e-02 [1.4e-02,1.5e-02] | 1010/150000 6.73e-03 [6.3e-03,7.2e-03] | 333/150000 2.22e-03 [2.0e-03,2.5e-03] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 1.44e-02 vs pecos_uf:bp 1.48e-02 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 6.73e-03 vs pecos_uf:bp 1.09e-02 (1.6x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 2.22e-03 vs pecos_uf:bp 4.41e-03 (2.0x) -- Jeffreys intervals DISJOINT + +### p = 0.005 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 5711/150000 3.81e-02 [3.7e-02,3.9e-02] | 4316/150000 2.88e-02 [2.8e-02,3.0e-02] | 2636/150000 1.76e-02 [1.7e-02,1.8e-02] | +| pecos_uf:bp | 5803/150000 3.87e-02 [3.8e-02,4.0e-02] | 5967/150000 3.98e-02 [3.9e-02,4.1e-02] | 4051/150000 2.70e-02 [2.6e-02,2.8e-02] | +| belief_matching | 5730/150000 3.82e-02 [3.7e-02,3.9e-02] | 4316/150000 2.88e-02 [2.8e-02,3.0e-02] | 2636/150000 1.76e-02 [1.7e-02,1.8e-02] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 3.81e-02 vs pecos_uf:bp 3.87e-02 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 2.88e-02 vs pecos_uf:bp 3.98e-02 (1.4x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 1.76e-02 vs pecos_uf:bp 2.70e-02 (1.5x) -- Jeffreys intervals DISJOINT + +### cx: suppression exponent (LER ~ (p/p_th)^((d+1)/2)) + +- p=0.002 fusion_blossom_serial: suppresses; per-step LER ratio 3.5x, 5.0x +- p=0.002 pecos_uf:bp: suppresses; per-step LER ratio 1.9x, 3.5x +- p=0.002 belief_matching: suppresses; per-step LER ratio 3.5x, 5.0x +- p=0.003 fusion_blossom_serial: suppresses; per-step LER ratio 2.1x, 3.0x +- p=0.003 pecos_uf:bp: suppresses; per-step LER ratio 1.4x, 2.5x +- p=0.003 belief_matching: suppresses; per-step LER ratio 2.1x, 3.0x +- p=0.005 fusion_blossom_serial: suppresses; per-step LER ratio 1.3x, 1.6x +- p=0.005 pecos_uf:bp: NOT monotone; per-step LER ratio 1.0x, 1.5x +- p=0.005 belief_matching: suppresses; per-step LER ratio 1.3x, 1.6x + +## memory: distance suppression + ranking + +### p = 0.002 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| pecos_uf:bp | 425/300000 1.42e-03 [1.3e-03,1.6e-03] | 280/300000 9.33e-04 [8.3e-04,1.0e-03] | 62/300000 2.07e-04 [1.6e-04,2.6e-04] | +| belief_matching | 420/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| pymatching | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| tesseract | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | + +- d=3: best **belief_matching** 1.40e-03 vs pecos_uf:bp 1.42e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 3.70e-04 vs pecos_uf:bp 9.33e-04 (2.5x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 9.33e-05 vs pecos_uf:bp 2.07e-04 (2.2x) -- Jeffreys intervals DISJOINT + +### p = 0.003 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 375/300000 1.25e-03 [1.1e-03,1.4e-03] | 129/300000 4.30e-04 [3.6e-04,5.1e-04] | +| pecos_uf:bp | 944/300000 3.15e-03 [3.0e-03,3.4e-03] | 738/300000 2.46e-03 [2.3e-03,2.6e-03] | 233/300000 7.77e-04 [6.8e-04,8.8e-04] | +| belief_matching | 933/300000 3.11e-03 [2.9e-03,3.3e-03] | 375/300000 1.25e-03 [1.1e-03,1.4e-03] | 129/300000 4.30e-04 [3.6e-04,5.1e-04] | +| pymatching | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 376/300000 1.25e-03 [1.1e-03,1.4e-03] | 130/300000 4.33e-04 [3.6e-04,5.1e-04] | +| tesseract | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 376/300000 1.25e-03 [1.1e-03,1.4e-03] | 131/300000 4.37e-04 [3.7e-04,5.2e-04] | + +- d=3: best **belief_matching** 3.11e-03 vs pecos_uf:bp 3.15e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 1.25e-03 vs pecos_uf:bp 2.46e-03 (2.0x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 4.30e-04 vs pecos_uf:bp 7.77e-04 (1.8x) -- Jeffreys intervals DISJOINT + +### p = 0.005 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 2418/300000 8.06e-03 [7.7e-03,8.4e-03] | 1616/300000 5.39e-03 [5.1e-03,5.7e-03] | 830/300000 2.77e-03 [2.6e-03,3.0e-03] | +| pecos_uf:bp | 2481/300000 8.27e-03 [8.0e-03,8.6e-03] | 2625/300000 8.75e-03 [8.4e-03,9.1e-03] | 1519/300000 5.06e-03 [4.8e-03,5.3e-03] | +| belief_matching | 2429/300000 8.10e-03 [7.8e-03,8.4e-03] | 1616/300000 5.39e-03 [5.1e-03,5.7e-03] | 830/300000 2.77e-03 [2.6e-03,3.0e-03] | +| pymatching | 2415/300000 8.05e-03 [7.7e-03,8.4e-03] | 1618/300000 5.39e-03 [5.1e-03,5.7e-03] | 828/300000 2.76e-03 [2.6e-03,3.0e-03] | +| tesseract | 2416/300000 8.05e-03 [7.7e-03,8.4e-03] | 1619/300000 5.40e-03 [5.1e-03,5.7e-03] | 833/300000 2.78e-03 [2.6e-03,3.0e-03] | + +- d=3: best **pymatching** 8.05e-03 vs pecos_uf:bp 8.27e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 5.39e-03 vs pecos_uf:bp 8.75e-03 (1.6x) -- Jeffreys intervals DISJOINT +- d=7: best **pymatching** 2.76e-03 vs pecos_uf:bp 5.06e-03 (1.8x) -- Jeffreys intervals DISJOINT + +### memory: suppression exponent (LER ~ (p/p_th)^((d+1)/2)) + +- p=0.002 fusion_blossom_serial: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 pecos_uf:bp: suppresses; per-step LER ratio 1.5x, 4.5x +- p=0.002 belief_matching: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 pymatching: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 tesseract: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.003 fusion_blossom_serial: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 pecos_uf:bp: suppresses; per-step LER ratio 1.3x, 3.2x +- p=0.003 belief_matching: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 pymatching: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 tesseract: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.005 fusion_blossom_serial: suppresses; per-step LER ratio 1.5x, 1.9x +- p=0.005 pecos_uf:bp: NOT monotone; per-step LER ratio 0.9x, 1.7x +- p=0.005 belief_matching: suppresses; per-step LER ratio 1.5x, 1.9x +- p=0.005 pymatching: suppresses; per-step LER ratio 1.5x, 2.0x +- p=0.005 tesseract: suppresses; per-step LER ratio 1.5x, 1.9x + +## memory: threshold crossing + +### fusion_blossom_serial + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.82e-03 | 2.72e-03 | 1.44e-03 | +| 0.005 | 7.40e-03 | 5.16e-03 | 3.16e-03 | +| 0.006 | 1.13e-02 | 8.86e-03 | 5.90e-03 | +| 0.007 | 1.44e-02 | 1.36e-02 | 1.06e-02 | +| 0.008 | 1.87e-02 | 2.05e-02 | 1.68e-02 | +| 0.009 | 2.35e-02 | 2.77e-02 | 2.53e-02 | +| 0.010 | 2.85e-02 | 3.55e-02 | 3.69e-02 | +| 0.012 | 3.95e-02 | 5.50e-02 | 6.57e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.009 + +### pecos_uf:bp + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.88e-03 | 5.30e-03 | 2.52e-03 | +| 0.005 | 7.62e-03 | 9.02e-03 | 5.18e-03 | +| 0.006 | 1.14e-02 | 1.38e-02 | 9.64e-03 | +| 0.007 | 1.47e-02 | 1.96e-02 | 1.62e-02 | +| 0.008 | 1.94e-02 | 2.77e-02 | 2.50e-02 | +| 0.009 | 2.41e-02 | 3.69e-02 | 3.50e-02 | +| 0.010 | 2.92e-02 | 4.65e-02 | 5.00e-02 | +| 0.012 | 4.03e-02 | 6.90e-02 | 8.44e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.007 + +### pymatching + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.82e-03 | 2.74e-03 | 1.44e-03 | +| 0.005 | 7.40e-03 | 5.18e-03 | 3.16e-03 | +| 0.006 | 1.12e-02 | 8.86e-03 | 5.92e-03 | +| 0.007 | 1.44e-02 | 1.37e-02 | 1.06e-02 | +| 0.008 | 1.87e-02 | 2.04e-02 | 1.68e-02 | +| 0.009 | 2.36e-02 | 2.78e-02 | 2.53e-02 | +| 0.010 | 2.86e-02 | 3.55e-02 | 3.69e-02 | +| 0.012 | 3.94e-02 | 5.51e-02 | 6.58e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.009 + +## speed (d=7, p=0.003, n per cell as sampled) + +| family | inner | build ms | decode s | us/shot | +|---|---|---:|---:|---:| +| cx | fusion_blossom_serial | 55.1 | 61.22 | 1224.5 | +| cx | belief_matching | 234.4 | 140.59 | 2811.8 | +| cx | pecos_uf:bp | 228.1 | 356.84 | 7136.8 | +| memory | pymatching | 11.6 | 1.56 | 31.1 | +| memory | fusion_blossom_serial | 11.3 | 9.03 | 180.5 | +| memory | belief_matching | 27.7 | 24.13 | 482.5 | +| memory | tesseract | 16.9 | 44.44 | 888.8 | +| memory | pecos_uf:bp | 26.3 | 49.61 | 992.2 | diff --git a/examples/surface/results/inner_decoder_study_speed.jsonl b/examples/surface/results/inner_decoder_study_speed.jsonl new file mode 100644 index 000000000..67662bee2 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_speed.jsonl @@ -0,0 +1,8 @@ +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.011340677971020341, "decode_seconds": 9.026471441960894} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 47, "ler": 0.00094, "build_seconds": 0.026346575003117323, "decode_seconds": 49.61077240493614} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.027656523045152426, "decode_seconds": 24.126216560951434} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.011642899014987051, "decode_seconds": 1.5562470420263708} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.016912557068280876, "decode_seconds": 44.43782857491169} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.055101476958952844, "decode_seconds": 61.224118691985495} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 217, "ler": 0.00434, "build_seconds": 0.2280546340625733, "decode_seconds": 356.84065975493286} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.2343938680132851, "decode_seconds": 140.59061060997192} diff --git a/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl b/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl new file mode 100644 index 000000000..a16f704c3 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl @@ -0,0 +1,18 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 814, "ler": 0.00814, "build_seconds": 0.0005815229378640652, "decode_seconds": 1.0498094509821385} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 841, "ler": 0.00841, "build_seconds": 0.001328750979155302, "decode_seconds": 0.8888753189239651} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 815, "ler": 0.00815, "build_seconds": 0.0007096950430423021, "decode_seconds": 0.4390910370275378} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 805, "ler": 0.00805, "build_seconds": 0.0005728199612349272, "decode_seconds": 1.0615145689807832} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 826, "ler": 0.00826, "build_seconds": 0.0008161291480064392, "decode_seconds": 0.8780001488048583} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 807, "ler": 0.00807, "build_seconds": 0.000710461987182498, "decode_seconds": 0.4365270962007344} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 836, "ler": 0.00836, "build_seconds": 0.000561530003324151, "decode_seconds": 1.042770282132551} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 854, "ler": 0.00854, "build_seconds": 0.0008523988071829081, "decode_seconds": 0.8763707531616092} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 833, "ler": 0.00833, "build_seconds": 0.0006987580563873053, "decode_seconds": 0.43262582598254085} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 534, "ler": 0.00534, "build_seconds": 0.003445373848080635, "decode_seconds": 8.91389643913135} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 897, "ler": 0.00897, "build_seconds": 0.005997291067615151, "decode_seconds": 22.61978363688104} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 533, "ler": 0.00533, "build_seconds": 0.0036377678625285625, "decode_seconds": 1.7914626270066947} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 566, "ler": 0.00566, "build_seconds": 0.003552536014467478, "decode_seconds": 8.98982253507711} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 901, "ler": 0.00901, "build_seconds": 0.006013470934703946, "decode_seconds": 22.358651204034686} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 568, "ler": 0.00568, "build_seconds": 0.0036590120289474726, "decode_seconds": 1.8368506969418377} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 573, "ler": 0.00573, "build_seconds": 0.003564184997230768, "decode_seconds": 9.03868287592195} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 931, "ler": 0.00931, "build_seconds": 0.006468330975621939, "decode_seconds": 22.687049677129835} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 572, "ler": 0.00572, "build_seconds": 0.0038262868765741587, "decode_seconds": 1.8458779270295054} diff --git a/examples/surface/results/inner_decoder_study_suppress.jsonl b/examples/surface/results/inner_decoder_study_suppress.jsonl new file mode 100644 index 000000000..a5df4b383 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_suppress.jsonl @@ -0,0 +1,216 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.0006421069847419858, "decode_seconds": 0.4497824680292979} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 149, "ler": 0.00149, "build_seconds": 0.0014974679797887802, "decode_seconds": 0.4129953379742801} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 146, "ler": 0.00146, "build_seconds": 0.0009097249712795019, "decode_seconds": 1.0400518350070342} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.000768975936807692, "decode_seconds": 0.30314706708304584} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.0011475470382720232, "decode_seconds": 3.57746625400614} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0005435589700937271, "decode_seconds": 0.437479944084771} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 136, "ler": 0.00136, "build_seconds": 0.0008776090107858181, "decode_seconds": 0.395081341965124} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 137, "ler": 0.00137, "build_seconds": 0.0009364159777760506, "decode_seconds": 1.0369742000475526} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0007549140136688948, "decode_seconds": 0.2959932100493461} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0011641409946605563, "decode_seconds": 3.5544276010477915} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0005864349659532309, "decode_seconds": 0.43750640004873276} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 140, "ler": 0.0014, "build_seconds": 0.0008039840031415224, "decode_seconds": 0.3960577129619196} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 137, "ler": 0.00137, "build_seconds": 0.0009138500317931175, "decode_seconds": 1.032312617986463} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0007354009430855513, "decode_seconds": 0.28613320307340473} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0010396430734544992, "decode_seconds": 3.470524953911081} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0005425120471045375, "decode_seconds": 0.633861496928148} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 308, "ler": 0.00308, "build_seconds": 0.0007895899470895529, "decode_seconds": 0.5555906310910359} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 298, "ler": 0.00298, "build_seconds": 0.0008792480221018195, "decode_seconds": 1.4799694110406563} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0007408609380945563, "decode_seconds": 0.33822497306391597} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0010694570373743773, "decode_seconds": 4.372640290064737} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0005225250497460365, "decode_seconds": 0.6251439020270482} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 300, "ler": 0.003, "build_seconds": 0.0007970969891175628, "decode_seconds": 0.5422393509652466} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 303, "ler": 0.00303, "build_seconds": 0.0009121129987761378, "decode_seconds": 1.4369446540949866} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0007219089893624187, "decode_seconds": 0.33977618906646967} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0010611469624564052, "decode_seconds": 4.464067224995233} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0005535660311579704, "decode_seconds": 0.6583235840080306} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 336, "ler": 0.00336, "build_seconds": 0.0008649560622870922, "decode_seconds": 0.5674660198856145} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0009156609885394573, "decode_seconds": 1.5248000029241666} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0007595749339088798, "decode_seconds": 0.35373285110108554} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0011283719213679433, "decode_seconds": 4.603742911014706} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 839, "ler": 0.00839, "build_seconds": 0.0005783980013802648, "decode_seconds": 1.0560591499088332} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 859, "ler": 0.00859, "build_seconds": 0.0008050329051911831, "decode_seconds": 0.8307753570843488} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 842, "ler": 0.00842, "build_seconds": 0.0008946330053731799, "decode_seconds": 2.2287630209466442} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 838, "ler": 0.00838, "build_seconds": 0.0007100310176610947, "decode_seconds": 0.4149702109862119} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 838, "ler": 0.00838, "build_seconds": 0.0010134490439668298, "decode_seconds": 6.129969451925717} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 819, "ler": 0.00819, "build_seconds": 0.0005436060018837452, "decode_seconds": 1.0267086579697207} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 836, "ler": 0.00836, "build_seconds": 0.0008376280311495066, "decode_seconds": 0.8533820679876953} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 827, "ler": 0.00827, "build_seconds": 0.0009307489963248372, "decode_seconds": 2.2911229039309546} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 818, "ler": 0.00818, "build_seconds": 0.000733512919396162, "decode_seconds": 0.42581556702498347} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 818, "ler": 0.00818, "build_seconds": 0.0010085488902404904, "decode_seconds": 6.200439296080731} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0005461819237098098, "decode_seconds": 1.023707817075774} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 786, "ler": 0.00786, "build_seconds": 0.0008072979981079698, "decode_seconds": 0.8395827020285651} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0008926040027290583, "decode_seconds": 2.319871476967819} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 759, "ler": 0.00759, "build_seconds": 0.0007133319741114974, "decode_seconds": 0.43179583409801126} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0010716660181060433, "decode_seconds": 6.314711899962276} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.003559389035217464, "decode_seconds": 3.2722078029764816} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 86, "ler": 0.00086, "build_seconds": 0.0060471040196716785, "decode_seconds": 10.21703472896479} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.006321229040622711, "decode_seconds": 8.985465016914532} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.003913899068720639, "decode_seconds": 0.9927647470030934} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.005649265018291771, "decode_seconds": 17.831412710016593} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.0034006189089268446, "decode_seconds": 3.2840063450857997} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 88, "ler": 0.00088, "build_seconds": 0.005910877021960914, "decode_seconds": 10.341198201989755} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.006320501095615327, "decode_seconds": 9.032301379018463} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.0038215159438550472, "decode_seconds": 0.9886277749901637} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.005684125004336238, "decode_seconds": 17.790226757060736} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0034657069481909275, "decode_seconds": 3.2388331450056285} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 106, "ler": 0.00106, "build_seconds": 0.005772155011072755, "decode_seconds": 10.084181787911803} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.006229009013622999, "decode_seconds": 9.035242390003987} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0038418869953602552, "decode_seconds": 0.989809827064164} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0056845389772206545, "decode_seconds": 17.67726430098992} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.0035957690561190248, "decode_seconds": 5.040996249997988} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 229, "ler": 0.00229, "build_seconds": 0.005812851013615727, "decode_seconds": 14.782907075015828} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.006486502010375261, "decode_seconds": 12.593983304919675} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.003774462966248393, "decode_seconds": 1.2669156150659546} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.0057538190158084035, "decode_seconds": 26.34577722591348} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 143, "ler": 0.00143, "build_seconds": 0.0033786309650167823, "decode_seconds": 5.02246251096949} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 270, "ler": 0.0027, "build_seconds": 0.005627467064186931, "decode_seconds": 14.253194133983925} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 143, "ler": 0.00143, "build_seconds": 0.00640254991594702, "decode_seconds": 12.500117123010568} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 144, "ler": 0.00144, "build_seconds": 0.0037747110472992063, "decode_seconds": 1.2520127579336986} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 144, "ler": 0.00144, "build_seconds": 0.005499309976585209, "decode_seconds": 25.5488203499699} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.003438101033680141, "decode_seconds": 4.9035661809612066} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 239, "ler": 0.00239, "build_seconds": 0.0058507469948381186, "decode_seconds": 14.442335027037188} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.006360596977174282, "decode_seconds": 12.397279483033344} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.0038107429863885045, "decode_seconds": 1.262787204934284} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.005702464026398957, "decode_seconds": 25.919863046961837} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.0036036160308867693, "decode_seconds": 9.057322234031744} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 878, "ler": 0.00878, "build_seconds": 0.006103449035435915, "decode_seconds": 21.981844869907945} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.006224778015166521, "decode_seconds": 18.28489999193698} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.0037976870080456138, "decode_seconds": 1.8186134689021856} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.005775927915237844, "decode_seconds": 42.00563556107227} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 576, "ler": 0.00576, "build_seconds": 0.0034860699670389295, "decode_seconds": 8.885354830999859} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 875, "ler": 0.00875, "build_seconds": 0.006064192042686045, "decode_seconds": 21.99228063202463} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 576, "ler": 0.00576, "build_seconds": 0.006546488031744957, "decode_seconds": 18.443165402975865} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 577, "ler": 0.00577, "build_seconds": 0.003976376028731465, "decode_seconds": 1.8343121459474787} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 578, "ler": 0.00578, "build_seconds": 0.005711263045668602, "decode_seconds": 42.36042152903974} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 509, "ler": 0.00509, "build_seconds": 0.003392826998606324, "decode_seconds": 8.768765309010632} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 872, "ler": 0.00872, "build_seconds": 0.005992374033667147, "decode_seconds": 21.8824789240025} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 509, "ler": 0.00509, "build_seconds": 0.006520363036543131, "decode_seconds": 18.35858091490809} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 510, "ler": 0.0051, "build_seconds": 0.003585992963053286, "decode_seconds": 1.739908825024031} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 510, "ler": 0.0051, "build_seconds": 0.005436684004962444, "decode_seconds": 42.01746710005682} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.01198889291845262, "decode_seconds": 12.00824419897981} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 14, "ler": 0.00014, "build_seconds": 0.0289649119367823, "decode_seconds": 83.20337125204969} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.028456886997446418, "decode_seconds": 41.73888665006962} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.012472887057811022, "decode_seconds": 2.5712421860080212} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.01786184194497764, "decode_seconds": 61.58867626695428} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.012055136961862445, "decode_seconds": 12.108281126944348} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 24, "ler": 0.00024, "build_seconds": 0.027866789023391902, "decode_seconds": 83.1739500790136} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.028778383042663336, "decode_seconds": 40.38781055691652} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.011742246919311583, "decode_seconds": 2.514463031082414} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.017679139971733093, "decode_seconds": 61.27467135200277} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.01150249200873077, "decode_seconds": 11.53979802003596} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 24, "ler": 0.00024, "build_seconds": 0.02652843203395605, "decode_seconds": 80.4659172029933} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.027839287999086082, "decode_seconds": 39.69481441995595} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.011705885990522802, "decode_seconds": 2.405015271040611} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.01666771702002734, "decode_seconds": 59.89127721195109} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.011185246054083109, "decode_seconds": 18.059501508949324} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 73, "ler": 0.00073, "build_seconds": 0.02570761798415333, "decode_seconds": 103.05704250000417} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.027981007006019354, "decode_seconds": 49.33750203391537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.012218795018270612, "decode_seconds": 3.1121007680194452} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.017130585969425738, "decode_seconds": 88.9494686650578} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.011477769003249705, "decode_seconds": 17.931140725966543} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 82, "ler": 0.00082, "build_seconds": 0.025771027081646025, "decode_seconds": 100.77474934596103} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.02863633690867573, "decode_seconds": 49.05798025405966} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 46, "ler": 0.00046, "build_seconds": 0.011999138980172575, "decode_seconds": 3.068818107014522} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.016575157991610467, "decode_seconds": 88.56133615795989} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.011320184916257858, "decode_seconds": 18.141521485056728} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 78, "ler": 0.00078, "build_seconds": 0.02584795793518424, "decode_seconds": 101.07802409003489} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.029297174070961773, "decode_seconds": 48.16394220502116} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.011357437004335225, "decode_seconds": 3.0276183639653027} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.01649698195978999, "decode_seconds": 88.96721229003742} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.011295111034996808, "decode_seconds": 33.22787524398882} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 477, "ler": 0.00477, "build_seconds": 0.02625328896101564, "decode_seconds": 123.47734216309618} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.026949655963107944, "decode_seconds": 64.25777647399809} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.011809082003310323, "decode_seconds": 4.572257385938428} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 246, "ler": 0.00246, "build_seconds": 0.016887197038158774, "decode_seconds": 153.30489724094514} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 290, "ler": 0.0029, "build_seconds": 0.011330893030390143, "decode_seconds": 33.7523007580312} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 527, "ler": 0.00527, "build_seconds": 0.027414581971243024, "decode_seconds": 125.70785540400539} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 290, "ler": 0.0029, "build_seconds": 0.028182174894027412, "decode_seconds": 65.73475300311111} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 288, "ler": 0.00288, "build_seconds": 0.012029790901578963, "decode_seconds": 4.614306694013067} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 288, "ler": 0.00288, "build_seconds": 0.018439115956425667, "decode_seconds": 153.3892400059849} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.011190642951987684, "decode_seconds": 33.50160719000269} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 515, "ler": 0.00515, "build_seconds": 0.026636217022314668, "decode_seconds": 123.7905317270197} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.02799793507438153, "decode_seconds": 65.43267240794376} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.012005220050923526, "decode_seconds": 4.573971158941276} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 299, "ler": 0.00299, "build_seconds": 0.017136757029220462, "decode_seconds": 152.66755335300695} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.0027666680980473757, "decode_seconds": 1.2426382739795372} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 347, "ler": 0.00694, "build_seconds": 0.003978513996116817, "decode_seconds": 1.9058792720315978} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 341, "ler": 0.00682, "build_seconds": 0.0043051280081272125, "decode_seconds": 4.2842509569600224} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 361, "ler": 0.00722, "build_seconds": 0.0029481599340215325, "decode_seconds": 1.277993755065836} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 373, "ler": 0.00746, "build_seconds": 0.004023088025860488, "decode_seconds": 1.8912213450530544} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 362, "ler": 0.00724, "build_seconds": 0.0045119550777599216, "decode_seconds": 4.184288542019203} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.0025959149934351444, "decode_seconds": 1.2000236450694501} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 349, "ler": 0.00698, "build_seconds": 0.003879523021169007, "decode_seconds": 1.831096573965624} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.004237468005158007, "decode_seconds": 4.154158256016672} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 695, "ler": 0.0139, "build_seconds": 0.0026430010329931974, "decode_seconds": 1.7629588880809024} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 723, "ler": 0.01446, "build_seconds": 0.003869635984301567, "decode_seconds": 2.6145379460649565} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 694, "ler": 0.01388, "build_seconds": 0.004219696973450482, "decode_seconds": 5.9179590420098975} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 736, "ler": 0.01472, "build_seconds": 0.002717138035222888, "decode_seconds": 1.831788704963401} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 760, "ler": 0.0152, "build_seconds": 0.004061102983541787, "decode_seconds": 2.6863492289558053} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 739, "ler": 0.01478, "build_seconds": 0.004333141027018428, "decode_seconds": 6.091107173007913} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 727, "ler": 0.01454, "build_seconds": 0.0027309979777783155, "decode_seconds": 1.8216422849800438} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 734, "ler": 0.01468, "build_seconds": 0.003968781093135476, "decode_seconds": 2.7072006149683148} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 726, "ler": 0.01452, "build_seconds": 0.004328525043092668, "decode_seconds": 5.964680672972463} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1900, "ler": 0.038, "build_seconds": 0.0027084420435130596, "decode_seconds": 3.0450374559732154} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1933, "ler": 0.03866, "build_seconds": 0.004028124967589974, "decode_seconds": 4.224082892993465} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1922, "ler": 0.03844, "build_seconds": 0.004390742047689855, "decode_seconds": 8.884535679011606} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1924, "ler": 0.03848, "build_seconds": 0.0029172690119594336, "decode_seconds": 3.0538512399652973} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1959, "ler": 0.03918, "build_seconds": 0.004021001048386097, "decode_seconds": 4.20720031298697} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1936, "ler": 0.03872, "build_seconds": 0.0043352419743314385, "decode_seconds": 8.902371066971682} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1887, "ler": 0.03774, "build_seconds": 0.002712189918383956, "decode_seconds": 3.042711588088423} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1911, "ler": 0.03822, "build_seconds": 0.0040241440292447805, "decode_seconds": 4.225115368957631} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1872, "ler": 0.03744, "build_seconds": 0.004651825060136616, "decode_seconds": 8.91394612903241} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 101, "ler": 0.00202, "build_seconds": 0.017809053068049252, "decode_seconds": 10.11937194596976} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 202, "ler": 0.00404, "build_seconds": 0.040036563063040376, "decode_seconds": 56.461156163946725} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 101, "ler": 0.00202, "build_seconds": 0.041228037094697356, "decode_seconds": 31.25634205795359} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 105, "ler": 0.0021, "build_seconds": 0.017188239027746022, "decode_seconds": 9.704132576938719} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 188, "ler": 0.00376, "build_seconds": 0.0385180430021137, "decode_seconds": 55.710626682965085} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 105, "ler": 0.0021, "build_seconds": 0.042297302978113294, "decode_seconds": 31.37941742199473} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 88, "ler": 0.00176, "build_seconds": 0.01705125393345952, "decode_seconds": 9.60310909000691} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 166, "ler": 0.00332, "build_seconds": 0.039060045033693314, "decode_seconds": 56.601782400975935} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 88, "ler": 0.00176, "build_seconds": 0.04300876404158771, "decode_seconds": 32.065633705002256} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 342, "ler": 0.00684, "build_seconds": 0.017805316019803286, "decode_seconds": 15.855886935023591} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 543, "ler": 0.01086, "build_seconds": 0.04020364605821669, "decode_seconds": 75.02097634994425} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 342, "ler": 0.00684, "build_seconds": 0.04101130703929812, "decode_seconds": 39.980677679996006} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 308, "ler": 0.00616, "build_seconds": 0.019196123001165688, "decode_seconds": 15.783406346919946} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 530, "ler": 0.0106, "build_seconds": 0.03991413407493383, "decode_seconds": 74.85742094798479} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 308, "ler": 0.00616, "build_seconds": 0.04262624797411263, "decode_seconds": 40.36505612905603} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 360, "ler": 0.0072, "build_seconds": 0.017643426079303026, "decode_seconds": 15.781059438944794} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 568, "ler": 0.01136, "build_seconds": 0.040128167951479554, "decode_seconds": 72.37126195908058} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 360, "ler": 0.0072, "build_seconds": 0.042749025975354016, "decode_seconds": 40.39476935600396} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1419, "ler": 0.02838, "build_seconds": 0.017737832968123257, "decode_seconds": 28.877137659001164} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1997, "ler": 0.03994, "build_seconds": 0.038523137918673456, "decode_seconds": 92.57560077100061} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1419, "ler": 0.02838, "build_seconds": 0.04114609200041741, "decode_seconds": 54.23134226095863} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1495, "ler": 0.0299, "build_seconds": 0.018085039919242263, "decode_seconds": 29.113411615020595} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2000, "ler": 0.04, "build_seconds": 0.04000170307699591, "decode_seconds": 95.11584063793998} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1495, "ler": 0.0299, "build_seconds": 0.04291122790891677, "decode_seconds": 54.54225459403824} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1402, "ler": 0.02804, "build_seconds": 0.017674515023827553, "decode_seconds": 28.85841549898032} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1970, "ler": 0.0394, "build_seconds": 0.040045556030236185, "decode_seconds": 94.39712701388635} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1402, "ler": 0.02804, "build_seconds": 0.04084432800300419, "decode_seconds": 54.54200694710016} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 17, "ler": 0.00034, "build_seconds": 0.05726063495967537, "decode_seconds": 37.58960096305236} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 51, "ler": 0.00102, "build_seconds": 0.2170823459746316, "decode_seconds": 316.8492327040294} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 17, "ler": 0.00034, "build_seconds": 0.23379869293421507, "decode_seconds": 115.71196917700581} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.05516764707863331, "decode_seconds": 37.62585578695871} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 58, "ler": 0.00116, "build_seconds": 0.2244842719519511, "decode_seconds": 319.665760110016} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.23318979307077825, "decode_seconds": 116.37066508294083} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.056982876965776086, "decode_seconds": 37.65939962002449} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 50, "ler": 0.001, "build_seconds": 0.22406468004919589, "decode_seconds": 317.6037418489577} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.2336903209798038, "decode_seconds": 114.94639900908805} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.0555530849378556, "decode_seconds": 60.70033219899051} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 217, "ler": 0.00434, "build_seconds": 0.22634913795627654, "decode_seconds": 360.4764091570396} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.23468924802727997, "decode_seconds": 139.93496759200934} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 122, "ler": 0.00244, "build_seconds": 0.06277166202198714, "decode_seconds": 60.998767197015695} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 215, "ler": 0.0043, "build_seconds": 0.22652721300255507, "decode_seconds": 359.99144811194856} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 122, "ler": 0.00244, "build_seconds": 0.2339047659188509, "decode_seconds": 139.8659792370163} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 102, "ler": 0.00204, "build_seconds": 0.05706561403349042, "decode_seconds": 61.76871059194673} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 230, "ler": 0.0046, "build_seconds": 0.22566778305917978, "decode_seconds": 358.26359176193364} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 102, "ler": 0.00204, "build_seconds": 0.23369164892937988, "decode_seconds": 140.8752816610504} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 860, "ler": 0.0172, "build_seconds": 0.05626340501476079, "decode_seconds": 116.39792538096663} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1320, "ler": 0.0264, "build_seconds": 0.22628114593680948, "decode_seconds": 381.43563097610604} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 860, "ler": 0.0172, "build_seconds": 0.2335918080061674, "decode_seconds": 195.28046238503885} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 878, "ler": 0.01756, "build_seconds": 0.055321692023426294, "decode_seconds": 116.20386328105815} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1370, "ler": 0.0274, "build_seconds": 0.222290173987858, "decode_seconds": 382.33848310704343} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 878, "ler": 0.01756, "build_seconds": 0.2357181420084089, "decode_seconds": 196.46588010096457} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 898, "ler": 0.01796, "build_seconds": 0.058892372995615005, "decode_seconds": 117.09821905102581} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1361, "ler": 0.02722, "build_seconds": 0.226338843931444, "decode_seconds": 382.2485167060513} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 898, "ler": 0.01796, "build_seconds": 0.23742589203175157, "decode_seconds": 197.39164293906651} diff --git a/examples/surface/results/inner_decoder_study_threshold.jsonl b/examples/surface/results/inner_decoder_study_threshold.jsonl new file mode 100644 index 000000000..29010b06b --- /dev/null +++ b/examples/surface/results/inner_decoder_study_threshold.jsonl @@ -0,0 +1,72 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 241, "ler": 0.00482, "build_seconds": 0.000553303980268538, "decode_seconds": 0.41356655303388834} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 244, "ler": 0.00488, "build_seconds": 0.0007363270269706845, "decode_seconds": 0.35552097705658525} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 241, "ler": 0.00482, "build_seconds": 0.0006616739556193352, "decode_seconds": 0.19352870399598032} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 370, "ler": 0.0074, "build_seconds": 0.0005259430035948753, "decode_seconds": 0.5087154989596456} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 381, "ler": 0.00762, "build_seconds": 0.0007031150162220001, "decode_seconds": 0.42045116203371435} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 370, "ler": 0.0074, "build_seconds": 0.0005987919867038727, "decode_seconds": 0.21348594094160944} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 566, "ler": 0.01132, "build_seconds": 0.0005060170078650117, "decode_seconds": 0.6070600069360808} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 571, "ler": 0.01142, "build_seconds": 0.0008120449492707849, "decode_seconds": 0.4770000349963084} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 561, "ler": 0.01122, "build_seconds": 0.0006387030007317662, "decode_seconds": 0.2350762509740889} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 720, "ler": 0.0144, "build_seconds": 0.0005051549524068832, "decode_seconds": 0.7224671969888732} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 736, "ler": 0.01472, "build_seconds": 0.0007587990257889032, "decode_seconds": 0.5536875569960102} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 720, "ler": 0.0144, "build_seconds": 0.0006870540091767907, "decode_seconds": 0.2616626820527017} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 935, "ler": 0.0187, "build_seconds": 0.0005344880046322942, "decode_seconds": 0.8156614169711247} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 972, "ler": 0.01944, "build_seconds": 0.0007926550460979342, "decode_seconds": 0.6286538590211421} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 936, "ler": 0.01872, "build_seconds": 0.0006048210198059678, "decode_seconds": 0.282362152938731} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1177, "ler": 0.02354, "build_seconds": 0.0005155650433152914, "decode_seconds": 0.9224442170234397} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1203, "ler": 0.02406, "build_seconds": 0.0007628179155290127, "decode_seconds": 0.681600435054861} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1181, "ler": 0.02362, "build_seconds": 0.0007024700753390789, "decode_seconds": 0.30697357503231615} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1427, "ler": 0.02854, "build_seconds": 0.0005175839178264141, "decode_seconds": 1.0248573770513758} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1458, "ler": 0.02916, "build_seconds": 0.0008263279451057315, "decode_seconds": 0.7313043309841305} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1432, "ler": 0.02864, "build_seconds": 0.0006042990135028958, "decode_seconds": 0.33015464805066586} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1973, "ler": 0.03946, "build_seconds": 0.0005137789994478226, "decode_seconds": 1.2339211829239503} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2017, "ler": 0.04034, "build_seconds": 0.0007613759953528643, "decode_seconds": 0.8449526780750602} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1969, "ler": 0.03938, "build_seconds": 0.0006781750125810504, "decode_seconds": 0.38146582897752523} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 136, "ler": 0.00272, "build_seconds": 0.003294265945442021, "decode_seconds": 3.3930116021074355} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 265, "ler": 0.0053, "build_seconds": 0.005730609060265124, "decode_seconds": 9.166729047894478} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 137, "ler": 0.00274, "build_seconds": 0.0036312530282884836, "decode_seconds": 0.7465279749594629} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 258, "ler": 0.00516, "build_seconds": 0.0033381300745531917, "decode_seconds": 4.302522073034197} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 451, "ler": 0.00902, "build_seconds": 0.005688800010830164, "decode_seconds": 10.679329155012965} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 259, "ler": 0.00518, "build_seconds": 0.0035879879724234343, "decode_seconds": 0.8774489300558344} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 443, "ler": 0.00886, "build_seconds": 0.003356348956003785, "decode_seconds": 5.301479918998666} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 689, "ler": 0.01378, "build_seconds": 0.005740055930800736, "decode_seconds": 12.126563983038068} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 443, "ler": 0.00886, "build_seconds": 0.0035532600013539195, "decode_seconds": 1.0043239099904895} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 682, "ler": 0.01364, "build_seconds": 0.0032987690065056086, "decode_seconds": 6.343367288005538} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 978, "ler": 0.01956, "build_seconds": 0.005682545015588403, "decode_seconds": 13.27012179105077} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 684, "ler": 0.01368, "build_seconds": 0.003600129042752087, "decode_seconds": 1.148259557900019} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1023, "ler": 0.02046, "build_seconds": 0.0033241230994462967, "decode_seconds": 7.480666225892492} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1384, "ler": 0.02768, "build_seconds": 0.005689051002264023, "decode_seconds": 14.328783028991893} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1021, "ler": 0.02042, "build_seconds": 0.0036275250604376197, "decode_seconds": 1.2811408209381625} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1385, "ler": 0.0277, "build_seconds": 0.00330440909601748, "decode_seconds": 8.675665647955611} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1845, "ler": 0.0369, "build_seconds": 0.005962638999335468, "decode_seconds": 15.303825345006771} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1391, "ler": 0.02782, "build_seconds": 0.003699503024108708, "decode_seconds": 1.4179591269930825} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1776, "ler": 0.03552, "build_seconds": 0.0033197120064869523, "decode_seconds": 9.890155210043304} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2327, "ler": 0.04654, "build_seconds": 0.005759961088187993, "decode_seconds": 15.909984825993888} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1773, "ler": 0.03546, "build_seconds": 0.0034103929065167904, "decode_seconds": 1.5061595120932907} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 2749, "ler": 0.05498, "build_seconds": 0.0032265339978039265, "decode_seconds": 12.273728820029646} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 3452, "ler": 0.06904, "build_seconds": 0.005713721038773656, "decode_seconds": 17.117338757030666} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 2753, "ler": 0.05506, "build_seconds": 0.0035725990310311317, "decode_seconds": 1.8775908190291375} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 72, "ler": 0.00144, "build_seconds": 0.01094578206539154, "decode_seconds": 12.749464866006747} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 126, "ler": 0.00252, "build_seconds": 0.026317083975300193, "decode_seconds": 58.79540476400871} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 72, "ler": 0.00144, "build_seconds": 0.011541299987584352, "decode_seconds": 1.924756177002564} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 158, "ler": 0.00316, "build_seconds": 0.011019375058822334, "decode_seconds": 16.652649068040773} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 259, "ler": 0.00518, "build_seconds": 0.026470276061445475, "decode_seconds": 63.40331910492387} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 158, "ler": 0.00316, "build_seconds": 0.011802835972048342, "decode_seconds": 2.3041705150390044} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 295, "ler": 0.0059, "build_seconds": 0.011194723076187074, "decode_seconds": 20.679654374951497} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 482, "ler": 0.00964, "build_seconds": 0.0258271771017462, "decode_seconds": 65.64840204094071} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 296, "ler": 0.00592, "build_seconds": 0.011505651054903865, "decode_seconds": 2.7034300840459764} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 532, "ler": 0.01064, "build_seconds": 0.011078166076913476, "decode_seconds": 25.844283557962626} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 812, "ler": 0.01624, "build_seconds": 0.025456094066612422, "decode_seconds": 68.38099301594775} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 532, "ler": 0.01064, "build_seconds": 0.01105475495569408, "decode_seconds": 2.9668737499741837} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 841, "ler": 0.01682, "build_seconds": 0.010775579023174942, "decode_seconds": 31.2043084789766} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1248, "ler": 0.02496, "build_seconds": 0.02808927302248776, "decode_seconds": 69.41384809394367} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 840, "ler": 0.0168, "build_seconds": 0.011174535960890353, "decode_seconds": 3.4064176470274106} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1267, "ler": 0.02534, "build_seconds": 0.010881343041546643, "decode_seconds": 36.750298622995615} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1748, "ler": 0.03496, "build_seconds": 0.02647972700651735, "decode_seconds": 72.80723491904791} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1267, "ler": 0.02534, "build_seconds": 0.011633733054623008, "decode_seconds": 3.9785961559973657} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1846, "ler": 0.03692, "build_seconds": 0.011132820975035429, "decode_seconds": 42.67777827405371} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2501, "ler": 0.05002, "build_seconds": 0.026440824032761157, "decode_seconds": 72.88538435997907} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1846, "ler": 0.03692, "build_seconds": 0.012233932968229055, "decode_seconds": 4.604135178029537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 3286, "ler": 0.06572, "build_seconds": 0.011454413994215429, "decode_seconds": 55.201751724933274} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 4218, "ler": 0.08436, "build_seconds": 0.02668835991062224, "decode_seconds": 74.66283174906857} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 3288, "ler": 0.06576, "build_seconds": 0.012077316991053522, "decode_seconds": 5.50001355400309} diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py new file mode 100644 index 000000000..cba6aac91 --- /dev/null +++ b/examples/surface/szz_circuit_quality_report.py @@ -0,0 +1,507 @@ +"""Report CX vs SZZ/SZZdg surface-memory circuit quality metrics. + +This script is intentionally descriptive rather than a threshold sweep. It +counts the gate locations that drive the simple circuit-level noise model and, +optionally, compares raw PECOS and Stim DEMs for the traced-QIS circuit path. +For SZZ/SZZdg cases the staged device model treats Z/SZ/SZdg frame updates as +noiseless virtual operations, so p1 location comparisons include that +assumption as well as the gate-basis change. + +Example: + uv run python examples/surface/szz_circuit_quality_report.py \\ + --distances 3 5 --bases X Z --interaction-bases cx szz +""" + +from __future__ import annotations + +import argparse +import json +import time +from collections import Counter +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from dem_decomposition_diagnostics import compare_raw_dems, dem_stats +from pecos.qec.surface import NoiseModel, OpType, SurfacePatch, build_surface_code_circuit +from pecos.qec.surface.circuit_builder import ( + _analyze_szz_forward_flow, + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, +) +from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, +) +from pecos.quantum import PHYSICAL_DURATION_META_KEY + +PREP_GATES = {"PZ", "PX", "PY", "QAlloc"} +MEASUREMENT_GATES = {"MZ", "MX", "MY", "MeasureFree"} +IDLE_GATES = {"Idle", "I"} +TWO_QUBIT_GATES = { + "CX", + "CY", + "CZ", + "CH", + "CRZ", + "SXX", + "SXXdg", + "SYY", + "SYYdg", + "SZZ", + "SZZdg", + "RXX", + "RYY", + "RZZ", + "SWAP", + "ISWAP", +} +THREE_QUBIT_GATES = {"CCX", "CCZ"} +SZZ_ABSTRACT_PREFIX_P1_FREE_GATES = {"Z", "SZ", "SZdg"} +SZZ_Z_FRAME_P1_FREE_SOURCES = {"abstract_physical_prefix", "traced_qis"} +# SZZ/SZZdg surface diagnostics model Z-frame gates as virtual and p1-free. +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} + + +@dataclass(frozen=True) +class TickCircuitStats: + source: str + build_s: float + total_ticks: int + nonempty_ticks: int + gate_batches: int + gate_locations: int + prep_locations: int + measurement_locations: int + single_qubit_locations: int + p1_model_locations: int + p1_exempt_locations: int + two_qubit_locations: int + idle_locations: int + zero_duration_locations: int + max_tick_width: int + gate_counts: dict[str, int] + first_order_fault_mass: dict[str, float] + + +@dataclass(frozen=True) +class AbstractCircuitStats: + step_counts: dict[str, int] + szz_forward_flow: dict[str, Any] | None + + +@dataclass(frozen=True) +class DemReport: + native_stats: dict[str, Any] + stim_stats: dict[str, Any] + native_vs_stim: dict[str, Any] + native_build_s: float + stim_build_s: float + + +@dataclass(frozen=True) +class CaseReport: + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + p1_ratio: float + abstract: AbstractCircuitStats + tick_circuits: list[TickCircuitStats] + dem: DemReport | None + + +def _gate_type_name(gate: Any) -> str: + gate_type = getattr(gate, "gate_type", "") + return str(getattr(gate_type, "name", gate_type)).rsplit(".", maxsplit=1)[-1] + + +def _gate_location_count(gate_name: str, qubits: list[int]) -> int: + if gate_name in TWO_QUBIT_GATES: + return len(qubits) // 2 + if gate_name in THREE_QUBIT_GATES: + return len(qubits) // 3 + return len(qubits) + + +def _is_zero_duration(tick: Any, gate_index: int) -> bool: + value = tick.get_gate_attr(gate_index, PHYSICAL_DURATION_META_KEY) + return value == 0 + + +def _noise_fault_mass( + *, + p: float, + p1_ratio: float, + p1_locations: int, + p2_locations: int, + prep_locations: int, + measurement_locations: int, + idle_locations: int, + p_idle: float, +) -> dict[str, float]: + p1 = p / p1_ratio + p_prep = p / 3.0 + p_meas = p / 3.0 + return { + "p1": p1_locations * p1, + "p2": p2_locations * p, + "prep": prep_locations * p_prep, + "measurement": measurement_locations * p_meas, + "idle": idle_locations * p_idle, + "total": p1_locations * p1 + + p2_locations * p + + prep_locations * p_prep + + measurement_locations * p_meas + + idle_locations * p_idle, + } + + +def _tick_circuit_stats( + *, + source: str, + tick_circuit: Any, + build_s: float, + interaction_basis: str, + p: float, + p1_ratio: float, + p_idle: float, +) -> TickCircuitStats: + gate_counts: Counter[str] = Counter() + total_locations = 0 + gate_batches = 0 + prep_locations = 0 + measurement_locations = 0 + single_qubit_locations = 0 + p1_exempt_locations = 0 + two_qubit_locations = 0 + idle_locations = 0 + zero_duration_locations = 0 + nonempty_ticks = 0 + max_tick_width = 0 + p1_exempt_names = ( + SZZ_ABSTRACT_PREFIX_P1_FREE_GATES + if source in SZZ_Z_FRAME_P1_FREE_SOURCES and interaction_basis == "szz" + else set() + ) + + for tick_index in range(int(tick_circuit.num_ticks())): + tick = tick_circuit.get_tick(tick_index) + if tick is None or tick.is_empty(): + continue + nonempty_ticks += 1 + tick_width = 0 + for gate_index, gate in enumerate(tick.gate_batches()): + gate_name = _gate_type_name(gate) + qubits = [int(qubit) for qubit in getattr(gate, "qubits", [])] + locations = _gate_location_count(gate_name, qubits) + gate_batches += 1 + total_locations += locations + tick_width += locations + gate_counts[gate_name] += locations + if _is_zero_duration(tick, gate_index): + zero_duration_locations += locations + if gate_name in PREP_GATES: + prep_locations += locations + elif gate_name in MEASUREMENT_GATES: + measurement_locations += locations + elif gate_name in IDLE_GATES: + idle_locations += locations + elif gate_name in TWO_QUBIT_GATES or gate_name in THREE_QUBIT_GATES: + two_qubit_locations += locations + else: + single_qubit_locations += locations + if gate_name in p1_exempt_names or _is_zero_duration(tick, gate_index): + p1_exempt_locations += locations + max_tick_width = max(max_tick_width, tick_width) + + p1_model_locations = single_qubit_locations - p1_exempt_locations + return TickCircuitStats( + source=source, + build_s=build_s, + total_ticks=int(tick_circuit.num_ticks()), + nonempty_ticks=nonempty_ticks, + gate_batches=gate_batches, + gate_locations=total_locations, + prep_locations=prep_locations, + measurement_locations=measurement_locations, + single_qubit_locations=single_qubit_locations, + p1_model_locations=p1_model_locations, + p1_exempt_locations=p1_exempt_locations, + two_qubit_locations=two_qubit_locations, + idle_locations=idle_locations, + zero_duration_locations=zero_duration_locations, + max_tick_width=max_tick_width, + gate_counts=dict(sorted(gate_counts.items())), + first_order_fault_mass=_noise_fault_mass( + p=p, + p1_ratio=p1_ratio, + p1_locations=p1_model_locations, + p2_locations=two_qubit_locations, + prep_locations=prep_locations, + measurement_locations=measurement_locations, + idle_locations=idle_locations, + p_idle=p_idle, + ), + ) + + +def _timed(callback: Any) -> tuple[Any, float]: + start = time.perf_counter() + value = callback() + return value, time.perf_counter() - start + + +def _abstract_stats(patch: SurfacePatch, rounds: int, basis: str, interaction_basis: str) -> AbstractCircuitStats: + ops, _allocation = build_surface_code_circuit( + patch, + rounds, + basis=basis, + interaction_basis=interaction_basis, + ) + step_counts = Counter(op.op_type.name for op in ops if op.op_type not in {OpType.COMMENT, OpType.TICK}) + flow = None + if interaction_basis == "szz": + summary = _analyze_szz_forward_flow(ops) + flow = asdict(summary) + flow.pop("pulses", None) + return AbstractCircuitStats(step_counts=dict(sorted(step_counts.items())), szz_forward_flow=flow) + + +def _build_tick_view( + *, + patch: SurfacePatch, + rounds: int, + basis: str, + interaction_basis: str, + source: str, +) -> tuple[Any, float]: + circuit_source = "abstract" + szz_physical_prefixes = False + if source == "traced_qis": + circuit_source = "traced_qis" + elif source == "abstract_physical_prefix": + if interaction_basis != "szz": + msg = "abstract_physical_prefix is only meaningful for interaction_basis='szz'" + raise ValueError(msg) + szz_physical_prefixes = True + elif source != "abstract": + msg = f"unknown tick circuit source {source!r}" + raise ValueError(msg) + + tick_circuit, elapsed = _timed( + lambda: _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source=circuit_source, + interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, + ), + ) + if source == "traced_qis": + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ circuit quality report") + return tick_circuit, elapsed + + +def _dem_report( + *, + patch: SurfacePatch, + rounds: int, + basis: str, + interaction_basis: str, + tick_circuit: Any, + p: float, + p1_ratio: float, +) -> DemReport: + noise = NoiseModel(p1=p / p1_ratio, p2=p, p_prep=p / 3.0, p_meas=p / 3.0) + noise_args = { + "p1": noise.p1, + "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, + "p2": noise.p2, + "p_prep": noise.p_prep, + "p_meas": noise.p_meas, + } + native_dem, native_build_s = _timed( + lambda: generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + stim_dem, stim_build_s = _timed( + lambda: generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + return DemReport( + native_stats=asdict(dem_stats(native_dem)), + stim_stats=asdict(dem_stats(stim_dem)), + native_vs_stim=asdict(compare_raw_dems(native_dem, stim_dem)), + native_build_s=native_build_s, + stim_build_s=stim_build_s, + ) + + +def build_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + p1_ratio: float, + p_idle: float, + sources: list[str], + include_dem: bool, +) -> CaseReport: + patch = SurfacePatch.create(distance=distance) + abstract = _abstract_stats(patch, rounds, basis, interaction_basis) + tick_stats: list[TickCircuitStats] = [] + traced_tick_circuit = None + + print(f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} ===", flush=True) + for source in sources: + tick_circuit, build_s = _build_tick_view( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + source=source, + ) + if source == "traced_qis": + traced_tick_circuit = tick_circuit + stats = _tick_circuit_stats( + source=source, + tick_circuit=tick_circuit, + build_s=build_s, + interaction_basis=interaction_basis, + p=p, + p1_ratio=p1_ratio, + p_idle=p_idle, + ) + tick_stats.append(stats) + mass = stats.first_order_fault_mass + print( + f"{source:24} ticks={stats.nonempty_ticks:5d}/{stats.total_ticks:<5d} " + f"p1_locs={stats.p1_model_locations:5d} " + f"p2_locs={stats.two_qubit_locations:5d} " + f"prep={stats.prep_locations:5d} meas={stats.measurement_locations:5d} " + f"mass={mass['total']:.6g} " + f"build={stats.build_s:.3f}s", + flush=True, + ) + + dem = None + if include_dem: + if traced_tick_circuit is None: + traced_tick_circuit, _build_s = _build_tick_view( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + source="traced_qis", + ) + print("building traced-QIS raw DEM comparison...", flush=True) + dem = _dem_report( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + tick_circuit=traced_tick_circuit, + p=p, + p1_ratio=p1_ratio, + ) + comparison = dem.native_vs_stim + print( + "raw native vs Stim: " + f"only_native={comparison['only_native']} only_stim={comparison['only_stim']} " + f"max_rel={comparison['max_rel_probability_diff']:.3e}", + flush=True, + ) + if ( + comparison["only_native"] == 0 + and comparison["only_stim"] == 0 + and comparison["max_rel_probability_diff"] > 0 + ): + print( + "raw structures match; max_rel is a probability-combination/rounding delta.", + flush=True, + ) + + return CaseReport( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + p1_ratio=p1_ratio, + abstract=abstract, + tick_circuits=tick_stats, + dem=dem, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X", "Z"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--sources", nargs="+", choices=["abstract", "abstract_physical_prefix", "traced_qis"]) + parser.add_argument("--p", type=float, default=0.006) + parser.add_argument("--p1-ratio", type=float, default=30.0, help="Use p1=p/p1_ratio.") + parser.add_argument("--p-idle", type=float, default=0.0, help="Optional idle rate for the rough mass estimate.") + parser.add_argument("--include-dem", action="store_true", help="Also compare traced-QIS raw native and Stim DEMs.") + parser.add_argument("--save-json", type=Path, default=None) + return parser.parse_args() + + +def _default_sources(interaction_basis: str) -> list[str]: + if interaction_basis == "szz": + return ["abstract", "abstract_physical_prefix", "traced_qis"] + return ["abstract", "traced_qis"] + + +def main() -> int: + args = parse_args() + results: list[CaseReport] = [] + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + for basis in args.bases: + for interaction_basis in args.interaction_bases: + sources = args.sources if args.sources is not None else _default_sources(interaction_basis) + results.append( + build_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=args.p, + p1_ratio=args.p1_ratio, + p_idle=args.p_idle, + sources=sources, + include_dem=args.include_dem, + ), + ) + + if args.save_json is not None: + args.save_json.parent.mkdir(parents=True, exist_ok=True) + args.save_json.write_text( + json.dumps([asdict(result) for result in results], indent=2, sort_keys=True), + encoding="utf-8", + ) + print(f"\nWrote {args.save_json}", flush=True) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/exp/guppy-zlup/.github/workflows/ci.yml b/exp/guppy-zlup/.github/workflows/ci.yml new file mode 100644 index 000000000..1c81a37e1 --- /dev/null +++ b/exp/guppy-zlup/.github/workflows/ci.yml @@ -0,0 +1,115 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Run tests + run: cargo test --all-features + working-directory: exp/guppy-zlup + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + with: + components: clippy, rustfmt + + - name: Check formatting + run: cargo fmt --check + working-directory: exp/guppy-zlup + + - name: Clippy + run: cargo clippy --all-features -- -D warnings + working-directory: exp/guppy-zlup + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Build + run: cargo build --release --features cli + working-directory: exp/guppy-zlup + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: guppy-zlup + path: target/release/guppy-zlup + + validate: + name: Validate with guppylang + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install guppylang + run: uv pip install guppylang --system + + - name: Build CLI + run: cargo build --features cli + working-directory: exp/guppy-zlup + + - name: Test validation script + run: | + # Create a test file + cat > /tmp/test_guppy.py << 'EOF' + from guppylang import guppy + from guppylang.std.quantum import qubit, h, measure + + @guppy + def test() -> bool: + q = qubit() + h(q) + return measure(q) + EOF + + # Run validation + cargo run --features cli -- validate /tmp/test_guppy.py + working-directory: exp/guppy-zlup diff --git a/exp/guppy-zlup/Cargo.toml b/exp/guppy-zlup/Cargo.toml new file mode 100644 index 000000000..678ef222c --- /dev/null +++ b/exp/guppy-zlup/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "guppy-zlup" +version = "0.1.0" +edition = "2024" +description = "Guppy to Zlup compiler" +license = "Apache-2.0" +repository = "https://github.com/PECOS-packages/PECOS" +readme = "README.md" +keywords = ["quantum", "compiler", "guppy", "zlup"] +categories = ["compilers", "command-line-utilities"] + +[lib] +name = "guppy_zlup" + +[[bin]] +name = "guppy-zlup" +path = "src/main.rs" +required-features = ["cli"] + +[dependencies] +# Python AST parsing (for linter) +rustpython-parser = "0.4" + +# Serialization +serde.workspace = true +serde_json.workspace = true +toml.workspace = true + +# Error handling +thiserror.workspace = true +miette = { version = "7.0", features = ["fancy"] } + +# Regex for noqa parsing +regex = "1.10" + +# CLI (optional) +clap = { workspace = true, optional = true } + +# Parallel processing (optional, for CLI) +rayon = { version = "1.10", optional = true } + +# File watching (optional, for CLI) +notify = { version = "6.1", optional = true } +notify-debouncer-mini = { version = "0.4", optional = true } + +# Link to zlup for AST types +zlup = { path = "../zlup" } + +[dev-dependencies] +pretty_assertions = "1.4" +insta = "1.40" +tempfile.workspace = true + +[features] +default = [] +cli = ["dep:clap", "dep:rayon", "dep:notify", "dep:notify-debouncer-mini"] diff --git a/exp/guppy-zlup/Justfile b/exp/guppy-zlup/Justfile new file mode 100644 index 000000000..0989b9c3f --- /dev/null +++ b/exp/guppy-zlup/Justfile @@ -0,0 +1,50 @@ +# Justfile for guppy-zlup documentation +# +# Uses the PECOS root's uv environment (which has mkdocs in dev deps) + +set shell := ["bash", "-cu"] + +# PECOS root directory (where pyproject.toml with mkdocs lives) +root := "../../.." + +# Default: serve docs +default: serve + +# Alias for consistency +docs port="8000": (serve port) + +# Serve docs locally (with hot reload, opens browser) +serve port="8000": + cd {{root}} && uv run mkdocs serve --open -a 127.0.0.1:{{port}} -f exp/guppy-zlup/mkdocs.yml + +# Serve docs without opening browser +serve-quiet port="8000": + cd {{root}} && uv run mkdocs serve -a 127.0.0.1:{{port}} -f exp/guppy-zlup/mkdocs.yml + +# Build static site +build: + cd {{root}} && uv run mkdocs build --clean -f exp/guppy-zlup/mkdocs.yml + +# Deploy to GitHub Pages +deploy: + cd {{root}} && uv run mkdocs gh-deploy --force -f exp/guppy-zlup/mkdocs.yml + +# Clean build artifacts +clean: + rm -rf site/ + +# Run tests +test: + cargo test + +# Build the CLI +build-cli: + cargo build --features cli --release + +# Check a Guppy file +check file: + cargo run --features cli -- check {{file}} + +# Compile a Guppy file to Zlup +compile file: + cargo run --features cli -- compile {{file}} --stdout diff --git a/exp/guppy-zlup/README.md b/exp/guppy-zlup/README.md new file mode 100644 index 000000000..c3fd7d207 --- /dev/null +++ b/exp/guppy-zlup/README.md @@ -0,0 +1,213 @@ +# guppy-zlup + +> **Note:** Zlup is an experimental toy language for exploring quantum programming +> language design concepts. This toolchain is for research and experimentation. + +A compiler toolchain for transforming Guppy quantum programs into Zlup, with +static analysis based on NASA's Power of 10 coding guidelines. + +## Overview + +guppy-zlup is a unified tool that: + +- **Validates** Guppy programs using guppylang (semantic validation) +- **Lints** against NASA Power of 10 safety rules (ZLUP001-010) +- **Emits** intermediate representation (IR) as JSON +- **Compiles** Guppy source or IR to Zlup source code +- **Verifies** generated Zlup is syntactically and semantically valid + +``` +Guppy Source (.py) + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ check │──▶ Diagnostics (errors, warnings) +└─────────────┘ + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ emit │──▶ Guppy IR (.json) +└─────────────┘ + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ compile │ +└─────────────┘ + │ + ▼ +Zlup Source (.zlp) +``` + +## Installation + +```bash +cargo install --path . --features cli +``` + +## Quick Start + +```bash +# Validate a Guppy file using guppylang (requires Python) +guppy-zlup validate program.py + +# Check a Guppy file for lint violations +guppy-zlup check program.py + +# Check with JSON output (for CI/tooling) +guppy-zlup check program.py --format json + +# Check with SARIF output (for GitHub Actions) +guppy-zlup check program.py --format sarif + +# Watch mode - re-lint on file changes +guppy-zlup check program.py --watch + +# Emit IR as JSON +guppy-zlup emit program.py -o program.json + +# Compile Guppy source directly to Zlup +guppy-zlup compile program.py -o program.zlp + +# Compile with guppylang validation first +guppy-zlup compile --validate program.py -o program.zlp + +# Or compile from existing IR JSON +guppy-zlup compile --ir program.json -o program.zlp + +# Analyze parallelism opportunities +guppy-zlup analyze program.py + +# Analyze with JSON output +guppy-zlup analyze program.py --format json + +# Compile and analyze in one step +guppy-zlup compile program.py --analyze +``` + +## Example + +Given a Guppy program `bell.py`: + +```python +def bell() -> None: + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) + m = measure(q) + result("measurements", m) +``` + +Run the toolchain: + +```bash +$ guppy-zlup check bell.py +No issues found. +All checks passed! + +$ guppy-zlup compile bell.py --stdout +fn bell() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + m := mz([2]u1) q; + result("measurements", m); + return; +} +``` + +## Lint Rules + +Based on NASA's Power of 10 coding guidelines for safety-critical systems: + +| Rule | Severity | Description | +|---------|----------|--------------------------------------| +| ZLUP001 | Error | Unbounded loops (while True) | +| ZLUP002 | Error | Recursive function calls | +| ZLUP003 | Error | Dynamic memory allocation in loops | +| ZLUP004 | Error | Dynamic dispatch (eval, getattr) | +| ZLUP005 | Warning | Unchecked error-prone operations | +| ZLUP006 | Warning | Missing type annotations | +| ZLUP007 | Warning | Excessive control flow complexity | +| ZLUP008 | Warning | Deep call nesting (>4 levels) | +| ZLUP009 | Info | Missing assertions in large functions| +| ZLUP010 | Warning | Global mutable state | + +### Suppressing Warnings + +Use `# noqa` comments to suppress specific warnings: + +```python +x = eval("expression") # noqa: ZLUP004 +``` + +## Validation Pipeline + +guppy-zlup performs multi-stage validation: + +1. **Syntax Check** - Python syntax via rustpython_parser +2. **Guppylang Validation** - Semantic validation via guppylang (optional, `--validate`) +3. **Lint Rules** - NASA Power of 10 safety checks (ZLUP001-010) +4. **IR Validation** - Schema and semantic checks on intermediate representation +5. **Transform Invariants** - Debug assertions during IR→Zlup transform +6. **Output Validation** - Generated Zlup is parsed and analyzed by Zlup's semantic analyzer + +This ensures both the input Guppy code and output Zlup code are valid. + +## Requirements + +- **Rust** 1.70+ for building +- **Python** 3.10+ with guppylang for validation (optional) + +Install guppylang for validation support: +```bash +uv pip install guppylang +# or +pip install guppylang +``` + +## Documentation + +- [docs/index.md](docs/index.md) - Full documentation index +- [docs/architecture.md](docs/architecture.md) - Pipeline design +- [docs/rules.md](docs/rules.md) - Lint rules reference +- [docs/ir-format.md](docs/ir-format.md) - IR JSON schema +- [docs/examples/](docs/examples/) - Example walkthroughs + +## Library Usage + +```rust +use guppy_zlup::{lint_source, compile, lint_and_compile, validate_ir, compile_with_roundtrip}; + +// Lint source code +let result = lint_source("def main(): pass", None); +if result.has_errors { + for diag in result.diagnostics { + println!("{}", diag); + } +} + +// Compile IR to Zlup +let zlup_source = compile(ir_json)?; + +// Full pipeline: lint + emit + validate IR + compile + validate output +let zlup_source = lint_and_compile(guppy_source, Some("example.py"))?; + +// Compile with round-trip validation (stricter) +let zlup_source = compile_with_roundtrip(ir_json)?; + +// Validate IR separately +let ir = guppy_zlup::ir::emit_ir(source, None)?; +let validation = validate_ir(&ir); +if !validation.is_valid() { + for error in &validation.errors { + println!("IR error: {}", error); + } +} +``` + +## License + +Apache-2.0 diff --git a/exp/guppy-zlup/docs/architecture.md b/exp/guppy-zlup/docs/architecture.md new file mode 100644 index 000000000..e511e0018 --- /dev/null +++ b/exp/guppy-zlup/docs/architecture.md @@ -0,0 +1,252 @@ +# Architecture + +This document describes the internal architecture of guppy-zlup. + +## Pipeline Overview + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ guppy-zlup │ +│ │ +│ Python Source (.py) │ +│ │ │ +│ ▼ rustpython-parser │ +│ Python AST (rustpython_parser::ast) │ +│ │ │ +│ ├──────────────────────┬──────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ Lint Rules (rules/*.rs) linter/lower.rs ir::emit_ir │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ Diagnostics Guppy AST Guppy IR (JSON) │ +│ (linter::ast) │ │ +│ │ │ +│ ▼ │ +│ ir::validate_ir ◄── IR Validation +│ │ │ +│ ▼ │ +│ compiler/parser.rs │ +│ │ │ +│ ▼ │ +│ compiler/transform.rs │ +│ (with invariant checks) │ +│ │ │ +│ ▼ │ +│ Zlup AST (zlup::ast) │ +│ │ │ +│ ▼ │ +│ zlup::pretty::pretty_print │ +│ │ │ +│ ▼ │ +│ Zlup Source (.zlp) │ +│ │ │ +│ ▼ │ +│ validate_zlup ◄── Output Validation +│ (parse + semantic analysis) │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +## AST Layers + +The toolchain uses multiple AST representations, each serving a specific purpose: + +### 1. Python AST (`rustpython_parser::ast`) + +The raw AST from parsing Python source code. This is a general-purpose Python +AST that includes all Python constructs, many of which aren't valid in Guppy. + +- **Source**: rustpython-parser crate +- **Used by**: `linter/lower.rs`, `ir.rs` + +### 2. Guppy AST (`linter::ast`) + +A clean, Guppy-specific AST that: +- Represents only constructs valid in Guppy +- Has first-class support for quantum operations (gates, measurements, qalloc) +- Isolates the codebase from Python parser API changes + +Key types: +```rust +pub struct Module { functions: Vec, ... } +pub struct Function { name: String, params: Vec, body: Vec, ... } +pub enum Stmt { Qalloc, Gate, Measure, For, While, If, ... } +pub enum Expr { IntLit, Name, BinOp, Call, ... } +pub enum GateKind { H, X, Y, Z, Cx, Cz, ... } +``` + +- **Source**: `src/linter/ast.rs` +- **Used by**: Lint rules + +### 3. Guppy IR (JSON) + +A serializable intermediate representation for tool interoperability: +- Can be consumed by any tool that reads JSON +- Enables caching of validated programs +- Allows alternative frontends to target the Zlup backend + +See [ir-format.md](./ir-format.md) for the full schema. + +- **Source**: `src/ir.rs` +- **Used by**: `emit` command (output), `compile --ir` command (input) + +### 4. Zlup AST (`zlup::ast`) + +The target language's AST. This is the canonical representation of Zlup +programs, used by the Zlup compiler for: +- Code generation (QASM, PHIR, HUGR) +- Optimization passes +- Semantic analysis + +- **Source**: `../zlup/src/ast.rs` +- **Used by**: `compiler/transform.rs`, Zlup pretty printer + +## Validation Layers + +The toolchain includes multiple validation layers to catch errors early and ensure correctness: + +### 1. IR Validation (`ir::validate_ir`) + +Validates the intermediate representation before transformation: +- **Schema validation**: Required fields present for each statement/expression kind +- **Semantic validation**: Variables defined before use, allocators exist before gate use +- **Gate arity**: Correct number of targets for each gate type +- **Operator validation**: Only known operators allowed + +```rust +let result = ir::validate_ir(&ir); +if !result.is_valid() { + // Handle errors +} +``` + +### 2. Transform Invariants + +Debug assertions during IR → Zlup transformation: +- Allocators must be registered before use in gates +- Variable scope tracking for assignments vs new bindings +- Consistency between qalloc_sizes and declared_vars + +These fire as panics in debug builds, catching internal bugs early. + +### 3. Output Validation (`validate_zlup`) + +Validates generated Zlup source by: +- Parsing it back through Zlup's parser +- Running Zlup's semantic analyzer (permissive mode) + +### 4. Round-Trip Validation (`validate_zlup_roundtrip`) + +Optional stricter validation: +- Compares original AST with re-parsed AST +- Verifies function counts, names, and parameter counts match +- Catches pretty-printer bugs + +## Design Decisions + +### Why a separate Guppy AST? + +1. **Parser isolation** - rustpython-parser API changes don't ripple through + the codebase +2. **Quantum-first** - Gates, measurements, and qalloc are first-class + constructs, not function calls +3. **Linting** - Easier to write lint rules against a clean AST + +### Why JSON for the IR? + +1. **Tool interop** - Other languages can emit Guppy IR +2. **Debugging** - Human-readable intermediate format +3. **Caching** - Save validated IR to disk + +### Why use Zlup's pretty printer? + +1. **Consistency** - Output matches Zlup's canonical style +2. **Maintenance** - One formatter to maintain, not two +3. **Correctness** - Zlup's formatter handles edge cases + +## Module Structure + +``` +src/ +├── lib.rs # Library entry point, public API +├── main.rs # CLI entry point +├── ir.rs # Guppy IR types and emit_ir() +├── compiler.rs # Compiler module +├── compiler/ +│ ├── parser.rs # JSON → GuppyIR +│ └── transform.rs # GuppyIR → zlup::ast::Program +├── linter.rs # Linter module +└── linter/ + ├── ast.rs # Guppy AST definitions + ├── config.rs # Configuration + ├── diagnostic.rs # Error/warning types + ├── engine.rs # Lint orchestration + ├── lower.rs # Python AST → Guppy AST + ├── rules.rs # Rules module + └── rules/ # Lint rule implementations + ├── zlup001.rs # Unbounded loops + ├── zlup002.rs # Recursion + ├── zlup003.rs # Dynamic allocation + ├── zlup004.rs # Dynamic dispatch + ├── zlup005.rs # Unchecked errors + ├── zlup006.rs # Missing types + ├── zlup007.rs # Complex control flow + ├── zlup008.rs # Deep call nesting + ├── zlup009.rs # Missing assertions + └── zlup010.rs # Global mutable state +``` + +## Key Transformations + +### Entry Function Returns + +In Guppy (following Quantinuum's design), entry/main functions must return `None`. +Values are emitted to the quantum runtime via explicit `result()` calls: + +**Guppy:** +```python +def main() -> None: + q = qubit[4] + h(q[0]) + cx(q[0], q[1]) + m = measure(q) + result("measurements", m) # Emit to runtime +``` + +**Zlup:** +```zlup +fn main() -> unit { + mut q := qalloc(4); + h q[0]; + cx (q[0], q[1]); + m := mz([4]u1) q; + result("measurements", m); + return; +} +``` + +This pattern: +- Entry functions return `None` (Guppy) / `unit` (Zlup) +- Results are explicitly tagged via `result(tag, value)` +- The quantum runtime collects all `result()` emissions + +## Extending the Toolchain + +### Adding a new lint rule + +1. Create `src/linter/rules/zlupNNN.rs` +2. Implement the `LintRule` trait +3. Register in `src/linter/rules.rs` +4. Add tests + +### Supporting a new Guppy construct + +1. Add the construct to `linter::ast` +2. Update `linter/lower.rs` to convert from Python AST +3. Update `ir.rs` to serialize/deserialize +4. Update `compiler/transform.rs` to emit Zlup + +### Alternative frontends + +Any tool can target guppy-zlup by emitting Guppy IR JSON. See +[ir-format.md](./ir-format.md) for the schema. diff --git a/exp/guppy-zlup/docs/examples/bell-state.md b/exp/guppy-zlup/docs/examples/bell-state.md new file mode 100644 index 000000000..0f9d58977 --- /dev/null +++ b/exp/guppy-zlup/docs/examples/bell-state.md @@ -0,0 +1,167 @@ +# Example: Bell State + +This example walks through compiling a simple Bell state preparation circuit +from Guppy to Zlup. + +## The Guppy Program + +A Bell state is a maximally entangled two-qubit state. Here's the Guppy code: + +```python +# bell.py +def bell() -> None: + """Prepare and measure a Bell state |Φ+⟩ = (|00⟩ + |11⟩) / √2""" + q = qubit[2] # Allocate 2 qubits + h(q[0]) # Hadamard on first qubit + cx(q[0], q[1]) # CNOT: control=q[0], target=q[1] + m = measure(q) # Measure both qubits + result("measurements", m) # Emit results to runtime +``` + +## Step 1: Lint the Code + +First, check for any rule violations: + +```bash +$ guppy-zlup check bell.py +No issues found. +All checks passed! +``` + +The program passes all checks: +- ✓ No unbounded loops (ZLUP001) +- ✓ No recursion (ZLUP002) +- ✓ No dynamic allocation in loops (ZLUP003) +- ✓ No dynamic dispatch (ZLUP004) +- ✓ Return type annotated (ZLUP006) + +## Step 2: Emit IR + +Generate the intermediate representation: + +```bash +$ guppy-zlup emit bell.py -o bell.json +Wrote IR to bell.json +``` + +The generated IR: + +```json +{ + "version": "0.1.0", + "source_file": "bell.py", + "functions": [ + { + "name": "bell", + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + }, + { + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] + }, + { + "kind": "assign", + "target": {"kind": "ident", "name": "m"}, + "value": { + "kind": "call", + "callee": "measure", + "args": [{"kind": "ident", "name": "q"}] + } + }, + { + "kind": "result", + "tag": "measurements", + "value": {"kind": "ident", "name": "m"} + } + ] + } + ] +} +``` + +## Step 3: Compile to Zlup + +Transform the IR to Zlup source code: + +```bash +$ guppy-zlup compile bell.py -o bell.zlp +``` + +The generated Zlup code: + +```zlup +fn bell() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + m := mz([2]u1) q; + result("measurements", m); + return; +} +``` + +Note: Entry/main functions return `unit`. Use explicit `result(tag, value)` calls to emit outputs to the quantum runtime. + +## Understanding the Transformation + +| Guppy | Zlup | Notes | +|-----------------------------|----------------------------|------------------------------| +| `q = qubit[2]` | `mut q := qalloc(2);` | Qubit allocation | +| `h(q[0])` | `h q[0];` | Gate as statement | +| `cx(q[0], q[1])` | `cx (q[0], q[1]);` | Multi-qubit gate | +| `m = measure(q)` | `m := mz([2]u1) q;` | Measurement with result | +| `result("tag", val)` | `result("tag", val);` | Emit value to runtime | +| `-> None` | `-> unit` | Entry functions return unit | + +### Measurement Variants + +Zlup supports flexible measurement syntax: + +```zlup +// Per-qubit into array (default) +m := mz([2]u1) q; + +// Single qubit +bit := mz(u1) q[0]; + +// Pack into custom struct (for QEC syndromes, etc.) +syndrome := mz(pack Syndrome) [ancilla[0], ancilla[1], ancilla[2]]; +``` + +## The Quantum Circuit + +The Bell state circuit: + +``` +q[0] ──H──●──M── + │ +q[1] ─────X──M── +``` + +1. **H gate** puts q[0] in superposition: |0⟩ → (|0⟩ + |1⟩) / √2 +2. **CNOT** entangles the qubits: creates |Φ+⟩ = (|00⟩ + |11⟩) / √2 +3. **Measure** collapses to either |00⟩ or |11⟩ with equal probability + +## What's Next? + +The Zlup code can now be: + +- Compiled to QASM for hardware execution +- Optimized by the Zlup compiler +- Analyzed for resource usage diff --git a/exp/guppy-zlup/docs/examples/grover.md b/exp/guppy-zlup/docs/examples/grover.md new file mode 100644 index 000000000..9906df08b --- /dev/null +++ b/exp/guppy-zlup/docs/examples/grover.md @@ -0,0 +1,213 @@ +# Example: Grover's Algorithm + +This example demonstrates a more complex program with loops: a simplified +Grover's search iteration. + +## The Guppy Program + +```python +# grover.py +def grover_iteration(q: qubit[4], iterations: int) -> None: + """ + Perform Grover iterations on a 4-qubit register. + + This is a simplified version that applies: + 1. Oracle (placeholder: CZ on first two qubits) + 2. Diffusion operator (H gates + phase flip + H gates) + """ + for i in range(iterations): + # Oracle (simplified) + cz(q[0], q[1]) + + # Diffusion: H on all qubits + h(q[0]) + h(q[1]) + h(q[2]) + h(q[3]) + + # Phase flip would go here + + # Diffusion: H on all qubits again + h(q[0]) + h(q[1]) + h(q[2]) + h(q[3]) + + +def main() -> None: + """Run Grover's algorithm.""" + q = qubit[4] + + # Initialize superposition + h(q[0]) + h(q[1]) + h(q[2]) + h(q[3]) + + # Run iterations (optimal is ~π/4 * √N for N=16) + grover_iteration(q, 3) + + # Measure and emit results + m = measure(q) + result("measurements", m) +``` + +## Linting Results + +```bash +$ guppy-zlup check grover.py +No issues found. +All checks passed! +``` + +This program passes all checks: + +- ✓ **ZLUP001**: The `for` loop is bounded by `iterations` parameter +- ✓ **ZLUP002**: No recursive calls +- ✓ **ZLUP003**: No allocations inside the loop +- ✓ **ZLUP006**: All types annotated + +## What Would Fail? + +### Unbounded Loop (ZLUP001) + +```python +def bad_grover(q: qubit[4]) -> None: + while True: # ZLUP001: unbounded loop + cz(q[0], q[1]) + if converged(): + break +``` + +**Fix**: Use a bounded loop with maximum iterations. + +### Allocation in Loop (ZLUP003) + +```python +def bad_grover(iterations: int) -> None: + for i in range(iterations): + q = qubit[4] # ZLUP003: allocation in loop + h(q[0]) +``` + +**Fix**: Allocate qubits outside the loop. + +### Missing Types (ZLUP006) + +```python +def bad_grover(q, iterations): # ZLUP006: missing types + for i in range(iterations): + h(q[0]) +``` + +**Fix**: Add type annotations. + +## Generated IR + +```bash +$ guppy-zlup emit grover.py -o grover.json +``` + +Key parts of the IR for `grover_iteration`: + +```json +{ + "name": "grover_iteration", + "params": [ + {"name": "q", "type": {"kind": "qalloc", "size": {"kind": "literal", "value": 4}}}, + {"name": "iterations", "type": {"kind": "primitive", "name": "int"}} + ], + "body": [ + { + "kind": "for", + "var": "i", + "range": { + "start": {"kind": "literal", "value": 0}, + "end": {"kind": "ident", "name": "iterations"} + }, + "body": [ + {"kind": "gate", "gate": "cz", "targets": [...]}, + {"kind": "gate", "gate": "h", "targets": [...]}, + ... + ] + } + ] +} +``` + +## Generated Zlup + +```bash +$ guppy-zlup compile grover.json --stdout +``` + +```zlup +fn grover_iteration(q: &mut [4]qubit, iterations: i64) -> unit { + for 0..iterations |i| { + cz (q[0], q[1]); + h q[0]; + h q[1]; + h q[2]; + h q[3]; + h q[0]; + h q[1]; + h q[2]; + h q[3]; + } +} + +fn main() -> unit { + mut q := qalloc(4); + h q[0]; + h q[1]; + h q[2]; + h q[3]; + grover_iteration(&mut q, 3); + m := mz([4]u1) q; + result("measurements", m); + return; +} +``` + +## Key Observations + +### Loop Translation + +| Guppy | Zlup | +|--------------------------------|-------------------------| +| `for i in range(iterations):` | `for 0..iterations |i|` | +| `for i in range(0, n):` | `for 0..n |i|` | +| `for i in range(1, n, 2):` | `for 1..n:2 |i|` | + +### Type Mapping + +| Guppy | Zlup | +|----------------|-------------| +| `int` | `i64` | +| `float` | `f64` | +| `bool` | `bool` | +| `qubit[4]` | `qalloc(4)` | +| `None` | `unit` | + +## Circuit Visualization + +One Grover iteration: + +``` +q[0] ──●──H──────H── + │ +q[1] ──Z──H──────H── + +q[2] ─────H──────H── + +q[3] ─────H──────H── + │ │ + Oracle Diffusion +``` + +The full algorithm: +1. Initialize all qubits to |+⟩ (H gates) +2. Repeat O(√N) times: + - Apply oracle (marks solution) + - Apply diffusion (amplifies marked state) +3. Measure to get solution with high probability diff --git a/exp/guppy-zlup/docs/future/parallelism.md b/exp/guppy-zlup/docs/future/parallelism.md new file mode 100644 index 000000000..7a286fa69 --- /dev/null +++ b/exp/guppy-zlup/docs/future/parallelism.md @@ -0,0 +1,1205 @@ +# Parallelism in Zlup: Design Notes + +> **Note:** Zlup is an experimental toy language for exploring quantum programming +> language design. These notes are exploratory design discussions, not specifications +> for a production system. + +This document explores how Zlup's scope-aware allocator tracking could enable +automatic parallelization of quantum programs. + +## Design Philosophy: Parallelism Through Constraints + +**Zlup provides parallelism without threads, locks, or explicit parallel syntax.** + +But this isn't "implicit" parallelism where the compiler magically figures things out. +It's **constraint-based** parallelism: the type system explicitly constrains what +functions can access, and parallelism follows directly from those constraints. + +### The Core Principle + +> **Expressiveness through constraints, not features.** + +Instead of adding `@parallel` annotations or `spawn` keywords, we constrain the +language so that parallelism is **obvious and verifiable** from the code structure: + +| Constraint | What It Enables | +|------------|-----------------| +| No allocator param → can't touch qubits | Classical functions are trivially parallel with quantum | +| Allocators are owned, not aliased | Different names = different qubits = independent | +| Scopes define lifetimes | Explicit sync points without barrier syntax | +| Static allocation only | All parallelism decidable at compile time | + +This aligns with NASA Power of 10: don't add features to enable analysis—remove +possibilities until analysis is trivial. + +### Why Not Threads or Parallel Annotations? + +Threads and `@parallel` annotations solve problems Zlup doesn't have: + +| Traditional Problem | Zlup's Constraint-Based Solution | +|---------------------|----------------------------------| +| Shared mutable state | Allocators are owned, not shared | +| Data races | No aliasing - different names = different resources | +| What can parallelize? | Read the type signature | +| Where are sync points? | Scope boundaries | + +Adding explicit parallelism syntax would: + +1. **Duplicate information** already in the type system +2. **Add complexity** without enabling anything new +3. **Violate "explicit through constraints"** by adding yet another mechanism + +### What the Programmer Writes vs. What Executes + +```zlup +// Programmer writes sequential code: +h q[0]; +h q[1]; +h q[2]; +cx (q[0], q[1]); +cx (q[1], q[2]); +``` + +``` +// Compiler sees dependency graph: +[h q[0]] ──────┐ + ├──► [cx (q[0],q[1])] ──┐ +[h q[1]] ──────┘ ├──► [cx (q[1],q[2])] + │ +[h q[2]] ─────────────────────────────►┘ + +// Execution can parallelize independent operations: +Layer 1: h q[0], h q[1], h q[2] (all parallel) +Layer 2: cx (q[0], q[1]) +Layer 3: cx (q[1], q[2]) +``` + +No annotations needed. The syntax already contains all the information. + +## The Core Insight + +Zlup tracks which qubit allocators are accessible in each scope. This information, +combined with the NASA Power of 10 constraints (no dynamic allocation, bounded loops, +no recursion), enables powerful static analysis for parallelism detection. + +```zlup +fn example() -> unit { + mut q1 := qalloc(2); // Allocator q1 enters scope + mut q2 := qalloc(2); // Allocator q2 enters scope + + // These blocks operate on disjoint qubit sets + { + h q1[0]; // Only touches q1 + cx (q1[0], q1[1]); + } + { + h q2[0]; // Only touches q2 + cx (q2[0], q2[1]); + } +} +``` + +The compiler knows statically that these two blocks are independent and can execute +in parallel on hardware that supports it. + +## Dependency Analysis + +### What the Compiler Knows + +At compile time, Zlup has complete information about: + +1. **Allocator lifetimes** - When each allocator enters and exits scope +2. **Allocator sizes** - Fixed at allocation time (no dynamic sizing) +3. **Operation targets** - Which allocator(s) each gate/measurement touches +4. **Control flow structure** - Bounded loops, no recursion + +This enables construction of a precise **operation dependency graph**. + +### Dependency Rules + +Two operations are **independent** (parallelizable) if: + +1. They operate on disjoint qubit sets, AND +2. Neither reads a classical variable written by the other, AND +3. No control flow dependency exists between them + +Two operations are **dependent** (must be ordered) if: + +1. They share at least one qubit target (read-after-write, write-after-write), OR +2. One reads a classical variable written by the other (data dependency), OR +3. One is control-dependent on the other (branch/loop) + +### Example: Building the Dependency Graph + +```zlup +fn grover_iteration(mut q: [4]qubit) -> unit { + // Operation 1: Oracle (all qubits) + oracle q; + + // Operation 2-5: Diffusion operator + h q[0]; h q[1]; h q[2]; h q[3]; // Ops 2,3,4,5 - independent of each other + + x q[0]; x q[1]; x q[2]; x q[3]; // Ops 6,7,8,9 - independent of each other + + // Multi-controlled Z + mcz q; // Op 10 - depends on 6,7,8,9 + + x q[0]; x q[1]; x q[2]; x q[3]; // Ops 11,12,13,14 + h q[0]; h q[1]; h q[2]; h q[3]; // Ops 15,16,17,18 +} +``` + +Dependency graph (simplified): +``` + [1:oracle] + | + +----+----+----+ + v v v v + [2:h] [3:h] [4:h] [5:h] <- Parallel layer + | | | | + v v v v + [6:x] [7:x] [8:x] [9:x] <- Parallel layer + | | | | + +----+----+----+ + v + [10:mcz] + | + ... (continues) +``` + +## Levels of Parallelism + +### 1. Gate-Level Parallelism + +Gates on independent qubits within the same allocator can execute simultaneously: + +```zlup +mut q := qalloc(4); +h q[0]; // Can run in parallel with... +h q[1]; // ...this gate +h q[2]; // ...and this one +h q[3]; // ...and this one +``` + +This is the finest granularity. Most quantum hardware naturally supports this. + +### 2. Block-Level Parallelism + +Independent code blocks operating on disjoint allocators: + +```zlup +mut ancilla := qalloc(2); +mut data := qalloc(4); + +// These blocks could execute on different QPU regions +{ + // Prepare ancilla + h ancilla[0]; + cx (ancilla[0], ancilla[1]); +} +{ + // Prepare data register (independent) + for i in 0..4 { + h data[i]; + } +} +// Sync point: both blocks must complete before continuing +``` + +### Scopes as Implicit Barriers + +Scopes naturally provide synchronization points when you need them: + +```zlup +{ + // Region 1: everything here completes... + h q1[0]; + cx (q1[0], q1[1]); +} +// ...before this point +{ + // Region 2: can depend on Region 1's results + if some_classical_result { + ... + } +} +``` + +But if you *don't* want a barrier, just don't use a scope: + +```zlup +h q1[0]; +h q2[0]; +// No barrier - compiler free to interleave +cx (q1[0], q1[1]); +cx (q2[0], q2[1]); +// q1 and q2 operations form independent chains +``` + +This gives programmers control over synchronization *when they need it*, without +requiring explicit barrier syntax for the common case (maximum parallelism). + +### 3. Classical-Quantum Parallelism + +Classical computation can proceed while quantum operations execute, as long as +no data dependencies exist: + +```zlup +mut q := qalloc(4); +classical_prep := expensive_classical_computation(); // Could overlap with qalloc + +h q[0]; +cx (q[0], q[1]); + +// Measurement creates a sync point +m := mz([4]u1) q; + +// Classical post-processing can start immediately +processed := process_results(m); +``` + +### 4. Inter-Function Parallelism + +With whole-program analysis, independent function calls could parallelize: + +```zlup +fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + + // These function calls are independent + prepare_bell(q1); // Only touches q1 + prepare_ghz(q2); // Only touches q2 +} +``` + +## Measurements and Sync Points + +Measurements introduce **implicit synchronization** because: + +1. The quantum state must be fully evolved before measurement +2. Classical variables holding results must be available before use + +```zlup +mut q := qalloc(2); +h q[0]; +cx (q[0], q[1]); + +// SYNC POINT: All operations on q must complete +m := mz([2]u1) q; + +// Classical code can now use m +if m[0] == 1 { + // Conditional logic based on measurement +} +``` + +### Mid-Circuit Measurement Considerations + +Mid-circuit measurement creates **partial sync points**: + +```zlup +mut q := qalloc(4); +h q[0]; h q[1]; h q[2]; h q[3]; + +// Only q[0] needs to sync here +m0 := mz([1]u1) q[0..1]; + +// These can continue in parallel with processing m0 +h q[1]; h q[2]; h q[3]; + +// Classical processing of m0 can happen concurrently +if m0[0] == 1 { + // This branch doesn't touch q[1..4] + log("measured 1"); +} +``` + +## QEC and Classical Processing + +Quantum Error Correction (QEC) is a critical use case that stress-tests the ownership +model. QEC involves tight classical-quantum interaction with real-time constraints. + +### The QEC Data Flow + +```zlup +fn qec_round(mut data: [n]qubit, mut ancilla: [m]qubit) -> unit { + // 1. Syndrome extraction (quantum operations) + extract_stabilizers(data, ancilla); + + // 2. Measure ancilla → classical syndrome + syndrome := mz([m]u1) ancilla; + + // 3. Decode (pure classical computation) + corrections := decode(syndrome); + + // 4. Apply corrections (quantum operations) + apply_corrections(data, corrections); + + // 5. Reset ancilla for next round + reset ancilla; +} +``` + +### What Works Well + +The ownership model handles QEC's core patterns naturally: + +| QEC Pattern | How Ownership Helps | +|-------------|---------------------| +| Syndrome arrays | Values after measurement - no aliasing, pass freely | +| Decoder parallelism | Pure functions on values - multiple decoders can run in parallel | +| Clear data flow | Explicit: qubits → measurement → classical → corrections → qubits | +| No race conditions | Syndrome is a value, not shared mutable state | + +```zlup +// Parallel decoders work naturally - syndrome is a value +fn fault_tolerant_decode(syndrome: [m]u1) -> Corrections { + c1 := decoder_mwpm(syndrome); // These three calls + c2 := decoder_uf(syndrome); // are independent + c3 := decoder_nn(syndrome); // (same input value) + return vote(c1, c2, c3); +} +``` + +### Challenging Patterns + +Some QEC patterns require more thought: + +#### Syndrome History (Sliding Window Decoders) + +MWPM, Union-Find, and other decoders often need multiple rounds of syndrome data: + +```zlup +// Option A: Pass history as a value (copied) +fn decode_with_history( + current: [m]u1, + history: [[m]u1; k] // Copied - potentially expensive +) -> Corrections { ... } + +// Option B: Borrow history (reference) +fn decode_with_history( + current: [m]u1, + history: &[[m]u1; k] // Borrowed - no copy, but adds reference semantics +) -> Corrections { ... } + +// Option C: Decoder owns accumulating state +struct Decoder { + history: [[m]u1; k], + // ... decoder state +} +fn decode(self: &mut Decoder, current: [m]u1) -> Corrections { + self.history.push(current); + // ... decode using history +} +``` + +#### Pipelined QEC + +Real QEC systems pipeline: decode round N while extracting round N+1. + +```zlup +fn pipelined_qec(mut data: [n]qubit, mut ancilla: [m]qubit) { + syndrome_prev := extract_and_measure(data, ancilla); + + for round in 1..num_rounds { + // GOAL: These should overlap: + // - decode(syndrome_prev) → pure classical + // - extract_stabilizers(...) → quantum on ancilla + + // But in sequential code, how does the compiler know? + extract_stabilizers(data, ancilla); // Quantum + corrections := decode(syndrome_prev); // Classical + syndrome_curr := mz([m]u1) ancilla; + apply_corrections(data, corrections); + syndrome_prev = syndrome_curr; + } +} +``` + +### Approaches to Classical-Quantum Overlap + +Three approaches for expressing that classical and quantum operations can overlap: + +#### Approach A: Implicit Inference + +The compiler analyzes data flow and infers what can overlap. + +**Problem:** This is implicit, not explicit. The programmer writes code without knowing +what will parallelize. This conflicts with Zlup's "explicit over implicit" philosophy. + +#### Approach B: Explicit Annotations + +Add syntax like `@parallel` or `overlap { }` blocks. + +**Problem:** This adds complexity. The programmer must learn new syntax and manually +annotate parallelism that the type system already expresses. + +#### Approach C: Structural Separation + +Separate classical and quantum into distinct "lanes" with explicit channels. + +**Problem:** Major language addition. Overkill for simple cases. Introduces concurrency +primitives (channels, send/recv) that we're trying to avoid. + +### Our Recommendation: Constraint-Based (No New Syntax) + +None of the above. Instead, recognize that **the type system already expresses parallelism**. + +The key insight, aligned with NASA Power of 10 Rule 6 (minimize scope): + +> **If a function doesn't take allocator parameters, it cannot touch qubits.** +> This is explicit, enforced by the compiler, and requires no annotation. + +```zlup +// EXPLICIT: "I take classical values, I return classical values, I cannot touch qubits" +fn decode(syndrome: [m]u1) -> Corrections { ... } + +// EXPLICIT: "I require mutable qubit access" +fn extract_stabilizers(mut data: [n]qubit, mut ancilla: [m]qubit) -> unit { ... } +``` + +The function signature IS the parallelism declaration: + +| Signature | What It Says | Parallelizable With | +|-----------|--------------|---------------------| +| `fn f(x: int) -> int` | Pure classical | Any quantum op | +| `fn f(syndrome: [m]u1) -> Corrections` | Classical only | Any quantum op | +| `fn f(mut q: [n]qubit) -> unit` | Needs qubit access | Only disjoint allocators | +| `fn f(mut q1: [n]qubit, mut q2: [m]qubit)` | Needs both | Nothing touching q1 or q2 | + +This is: +- **Explicit**: The constraint is visible in the type signature +- **Simple**: No new syntax, no annotations +- **Constraint-based**: Expressiveness through what you CAN'T do + +### Why This Aligns with NASA Power of 10 + +| Power of 10 Rule | How It Applies | +|------------------|----------------| +| Rule 3: No dynamic allocation | Allocators are static → known at compile time | +| Rule 6: Minimize scope | Narrow allocator scope → clear parallelism boundaries | +| Rule 9: Limit pointer use | No aliasing → if `q1` and `q2` are different names, they're different allocators | + +The Power of 10 philosophy: **Don't add features to enable analysis. Remove possibilities until analysis is trivial.** + +We don't add `@parallel`. We constrain functions so parallelism is obvious: +- No allocator param? Can't touch qubits. Done. +- Different allocator params? Different qubits. Done. + +### Scopes as Explicit Sync Points + +If you need ordering, use a scope: + +```zlup +{ + // Everything in this scope completes... + extract_stabilizers(data, ancilla); + syndrome := mz([m]u1) ancilla; +} +// ...before this point + +corrections := decode(syndrome); // Can overlap with next scope + +{ + apply_corrections(data, corrections); +} +``` + +Scopes are explicit barriers. No scope = no enforced ordering. This is already in the language. + +### Why This Works for QEC + +The constraint-based model is perfect for QEC because: + +1. **Decoders are pure classical by construction** + +```zlup +fn decode(syndrome: [m]u1) -> Corrections { + // No allocator parameters → CANNOT touch qubits + // This isn't a promise - it's enforced by the type system +} +``` + +2. **The classical/quantum boundary is explicit and verified** + +```zlup +fn qec_round(mut data: [n]qubit, mut ancilla: [m]qubit) -> unit { + extract_stabilizers(data, ancilla); // Signature says: needs qubits + syndrome := mz([m]u1) ancilla; // Measurement: quantum → classical + corrections := decode(syndrome); // Signature says: classical only + apply_corrections(data, corrections); // Signature says: needs qubits +} +``` + +3. **Parallelism is obvious from signatures** + +Looking at just the types: +- `decode: [m]u1 -> Corrections` — classical, parallelizable with any quantum +- `extract_stabilizers: (mut [n]qubit, mut [m]qubit) -> unit` — needs both allocators + +No analysis needed. No annotations. The constraints tell you everything. + +4. **NASA Power of 10 compliance comes free** + +The same constraints that enable parallelism also enforce safety: +- Static allocators (Rule 3) +- Minimal scope (Rule 6) +- No aliasing (Rule 9) + +## Realistic QEC Examples + +Let's work through concrete QEC scenarios to verify the constraint-based model works. + +### Example 1: Repetition Code (Simplest Case) + +A 3-qubit repetition code with 2 syndrome qubits: + +```zlup +// Data qubits: |ψ⟩ encoded as |ψψψ⟩ +// Syndrome qubits: measure ZZ stabilizers + +fn repetition_round(mut data: [3]qubit, mut syndrome: [2]qubit) -> [2]u1 { + // Syndrome extraction: ZZ stabilizers + cx (data[0], syndrome[0]); + cx (data[1], syndrome[0]); + cx (data[1], syndrome[1]); + cx (data[2], syndrome[1]); + + // Measure syndrome qubits + s := mz([2]u1) syndrome; + + // Reset for next round + reset syndrome; + + return s; +} + +// Decoder: pure classical, no allocator params +fn decode_repetition(s: [2]u1) -> [3]u1 { + // Simple majority voting + // s[0] = data[0] ⊕ data[1] + // s[1] = data[1] ⊕ data[2] + mut corrections := [0, 0, 0]; + if s[0] == 1 && s[1] == 0 { + corrections[0] = 1; // Error on qubit 0 + } else if s[0] == 1 && s[1] == 1 { + corrections[1] = 1; // Error on qubit 1 + } else if s[0] == 0 && s[1] == 1 { + corrections[2] = 1; // Error on qubit 2 + } + return corrections; +} + +fn apply_corrections(mut data: [3]qubit, corrections: [3]u1) -> unit { + for i in 0..3 { + if corrections[i] == 1 { + x data[i]; + } + } +} + +fn qec_cycle(mut data: [3]qubit, mut syndrome: [2]qubit) -> unit { + s := repetition_round(data, syndrome); // Quantum: touches data, syndrome + corrections := decode_repetition(s); // Classical: no allocators + apply_corrections(data, corrections); // Quantum: touches data +} +``` + +**Parallelism analysis from signatures:** +- `repetition_round`: needs `data` and `syndrome` → serialized with anything touching those +- `decode_repetition`: takes `[2]u1`, returns `[3]u1` → pure classical → can overlap +- `apply_corrections`: needs `data` → must wait for decode, serialized with data ops + +### Example 2: Surface Code Patch + +A distance-3 surface code with X and Z stabilizers: + +```zlup +// Surface code layout (distance 3): +// D0 -- Z0 -- D1 +// | | +// X0 D4 X1 +// | | +// D2 -- Z1 -- D3 + +struct SurfaceCodePatch { + data: [5]qubit, // 5 data qubits + x_ancilla: [2]qubit, // 2 X stabilizer ancillas + z_ancilla: [2]qubit, // 2 Z stabilizer ancillas +} + +fn extract_x_stabilizers( + mut data: [5]qubit, + mut x_ancilla: [2]qubit +) -> unit { + // X0 stabilizer: X on D0, D2, D4 + h x_ancilla[0]; + cx (x_ancilla[0], data[0]); + cx (x_ancilla[0], data[2]); + cx (x_ancilla[0], data[4]); + h x_ancilla[0]; + + // X1 stabilizer: X on D1, D3, D4 + h x_ancilla[1]; + cx (x_ancilla[1], data[1]); + cx (x_ancilla[1], data[3]); + cx (x_ancilla[1], data[4]); + h x_ancilla[1]; +} + +fn extract_z_stabilizers( + mut data: [5]qubit, + mut z_ancilla: [2]qubit +) -> unit { + // Z0 stabilizer: Z on D0, D1, D4 + cx (data[0], z_ancilla[0]); + cx (data[1], z_ancilla[0]); + cx (data[4], z_ancilla[0]); + + // Z1 stabilizer: Z on D2, D3, D4 + cx (data[2], z_ancilla[1]); + cx (data[3], z_ancilla[1]); + cx (data[4], z_ancilla[1]); +} + +fn surface_code_round(mut patch: SurfaceCodePatch) -> SurfaceSyndrome { + // X and Z extraction both need data qubits + // But they use different ancilla allocators + extract_x_stabilizers(patch.data, patch.x_ancilla); + extract_z_stabilizers(patch.data, patch.z_ancilla); + + // Measure all ancillas + x_syndrome := mz([2]u1) patch.x_ancilla; + z_syndrome := mz([2]u1) patch.z_ancilla; + + reset patch.x_ancilla; + reset patch.z_ancilla; + + return SurfaceSyndrome { x: x_syndrome, z: z_syndrome }; +} +``` + +**Key observation:** X and Z stabilizer extraction both touch `data`, so they serialize. +But within each extraction, gates on different ancilla qubits can parallelize. + +### Example 3: Pipelined QEC with Decoder + +The real challenge: overlapping decode(round N) with extract(round N+1): + +```zlup +fn pipelined_qec( + mut data: [5]qubit, + mut ancilla: [4]qubit, + num_rounds: int +) -> unit { + // First round: no previous syndrome to decode + syndrome_prev := extract_and_measure(data, ancilla); + + for round in 1..num_rounds { + // KEY INSIGHT: These have different signatures + // + // extract_and_measure: (mut [5]qubit, mut [4]qubit) -> Syndrome + // ^^^^^^^^^^^^ ^^^^^^^^^^^^^ + // needs qubit allocators + // + // decode: (Syndrome, SyndromeHistory) -> Corrections + // ^^^^^^^^^^^^^^^^^^^^^^^^^ + // pure classical values, NO allocators + + // The compiler sees: + // - extract touches {data, ancilla} + // - decode touches {} (no allocators) + // Therefore: decode can overlap with extract + + { + // Scope groups the quantum operations + extract_stabilizers(data, ancilla); + syndrome_curr := mz([4]u1) ancilla; + reset ancilla; + } + + // decode runs on previous syndrome - can overlap with scope above + corrections := decode_surface(syndrome_prev); + + // apply must wait for both decode AND current extraction + apply_corrections(data, corrections); + + syndrome_prev = syndrome_curr; + } + + // Final decode and correct + corrections := decode_surface(syndrome_prev); + apply_corrections(data, corrections); +} +``` + +**The constraint-based parallelism:** + +``` +Timeline: + Round 1 | Round 2 | + extract(data,ancilla) | extract(data,ancilla) | + | | | | + v | v | + syndrome_1 | syndrome_2 | + | | | | + +---- decode(s0) -+ +--- decode(s1) + + | | + v v + corrections_1 corrections_2 + | | + apply(data, c1) apply(data, c2) +``` + +Decode overlaps with extract because their signatures prove they're independent. + +### Example 4: Multi-Patch Logical Operations + +Multiple surface code patches with independent QEC: + +```zlup +struct LogicalQubit { + patch: SurfaceCodePatch, + // ... other metadata +} + +fn parallel_qec_rounds( + mut logical_qubits: [n]LogicalQubit +) -> unit { + // Each logical qubit has its own allocators + // They're completely independent + + for i in 0..n { + // These iterations touch disjoint allocators + // Compiler can parallelize across logical qubits + qec_round(logical_qubits[i].patch); + } +} + +fn transversal_cnot( + mut control: LogicalQubit, + mut target: LogicalQubit +) -> unit { + // Transversal gate: independent physical CNOTs + for i in 0..5 { + // Each CNOT is on different physical qubits + // within the same logical operation + cx (control.patch.data[i], target.patch.data[i]); + } +} +``` + +**Parallelism from structure:** +- Different `LogicalQubit` instances have different allocators → independent +- Loop iterations on independent allocators → parallelizable +- Within transversal gate: different qubit indices → parallelizable + +### Example 5: Decoder with Syndrome History + +Realistic decoders need history. How does ownership handle this? + +```zlup +// Option A: Decoder owns its history (stateful decoder) +struct MWPMDecoder { + history: [[4]u1; window_size], + window_pos: int, + // ... matching graph, etc. +} + +impl MWPMDecoder { + // Decoder is a classical object with classical methods + // No allocator params anywhere → all classical + fn decode(self: &mut MWPMDecoder, current: [4]u1) -> Corrections { + // Add to history + self.history[self.window_pos] = current; + self.window_pos = (self.window_pos + 1) % window_size; + + // Run MWPM on history window + // ... matching algorithm ... + + return corrections; + } +} + +fn qec_with_stateful_decoder( + mut data: [5]qubit, + mut ancilla: [4]qubit, + decoder: &mut MWPMDecoder, // Classical reference, no qubits + num_rounds: int +) -> unit { + for round in 0..num_rounds { + syndrome := extract_and_measure(data, ancilla); + + // decoder.decode takes classical values only + // Signature: (&mut MWPMDecoder, [4]u1) -> Corrections + // No qubit allocators → can overlap with quantum + corrections := decoder.decode(syndrome); + + apply_corrections(data, corrections); + } +} +``` + +**Key insight:** The decoder struct contains classical data only. Its methods +take `&mut self` (classical reference) and classical values. No allocator +parameters → provably doesn't touch qubits → safe to overlap. + +### What These Examples Demonstrate + +1. **Simple cases work naturally** — repetition code shows basic pattern +2. **Complex extraction parallelizes** — within stabilizer extraction, independent gates parallelize +3. **Pipelining works** — decode/extract overlap follows from signatures +4. **Multi-patch parallelizes** — different logical qubits are independent +5. **Stateful decoders work** — classical state doesn't affect qubit access + +All parallelism follows from reading the type signatures. No annotations needed. + +## Compiler Passes for Parallelism + +### Pass 1: Allocator Scope Analysis + +Build a map of allocator lifetimes and which scopes can access them: + +``` +allocator "q1": defined at line 5, scope depth 1, size 4 + accessible in: main (lines 5-50), helper (lines 20-30) +allocator "q2": defined at line 10, scope depth 1, size 2 + accessible in: main (lines 10-50) +``` + +### Pass 2: Operation Tagging + +Tag each operation with the allocators it touches: + +``` +h q1[0] -> touches: {q1} +cx (q1[0], q2[0]) -> touches: {q1, q2} +m := mz q1 -> touches: {q1}, defines: {m} +``` + +### Pass 3: Dependency Graph Construction + +Build edges between dependent operations: + +```rust +struct DepGraph { + nodes: Vec, + edges: Vec<(OpId, OpId, DepKind)>, // (from, to, kind) +} + +enum DepKind { + QubitDep(String), // Shared qubit allocator + DataDep(String), // Shared classical variable + ControlDep, // Control flow +} +``` + +### Pass 4: Parallelism Detection + +Find maximal independent sets (parallel layers): + +```rust +fn find_parallel_layers(graph: &DepGraph) -> Vec> { + // Topological sort with level assignment + // Operations at the same level are parallelizable +} +``` + +### Pass 5: Schedule Generation + +Generate a schedule respecting dependencies while maximizing parallelism: + +``` +Layer 0: [qalloc q1, qalloc q2] +Layer 1: [h q1[0], h q2[0]] <- parallel +Layer 2: [cx (q1[0],q1[1]), cx (q2[0],q2[1])] <- parallel +Layer 3: [mz q1] <- sync point for q1 +Layer 4: [mz q2, classical_op(m1)] <- q2 measure + classical parallel +``` + +## Hardware Considerations + +### CAN vs SHOULD Parallelize + +The constraint-based model tells us what **CAN** parallelize. But hardware +constraints determine what **SHOULD** parallelize. + +``` +Constraints (language level): Scheduling (compiler level): +───────────────────────────── ──────────────────────────── +"These operations are "Given hardware limits, + independent" what's the best schedule?" + │ │ + ▼ ▼ +Type signatures Architecture + Noise model +Allocator ownership Control system parallelism +Scope boundaries Decoherence times +``` + +The language expresses independence. The compiler decides how to exploit it. + +### Limited Parallel Control + +Some hardware architectures have constrained parallelism due to: + +- Limited control resources (only N operations simultaneously) +- Shared control channels across qubit subsets +- Control electronics bottlenecks + +In these cases, **serializing a QEC gadget may be better** than spreading +resources across parallel operations: + +``` +Option A: Parallelize across patches (spread resources) +──────────────────────────────────────────────────────── +Patch 1: ░░░ extract ░░░░░░░░░░░░░░░░░░░░ +Patch 2: ░░░ extract ░░░░░░░░░░░░░░░░░░░░ + ^^^ + Shared control resources → both run slow + +Option B: Serialize patches (concentrate resources) +──────────────────────────────────────────────────────── +Patch 1: ▓▓ extract ▓▓ +Patch 2: ▓▓ extract ▓▓ + ^^^ + Full resources → each runs fast +``` + +For QEC with decoherence pressure, Option B may win: finish each syndrome +extraction quickly rather than have both running slowly. + +### What the Language Expresses vs What the Compiler Decides + +| Concern | Language (Constraints) | Compiler (Scheduling) | +|---------|------------------------|----------------------| +| Independence | Type signatures | — | +| Ordering requirements | Scope boundaries | — | +| Hardware parallelism limits | — | Architecture model | +| Decoherence optimization | — | Noise model | +| Resource allocation | — | Control system model | + +The language should express **what's possible**. The compiler should decide +**what's optimal** given the target hardware. + +### Philosophy: Start Simple, Add Knobs If Needed + +Our approach: + +1. **Language stays simple** — constraints express independence, nothing more +2. **Compiler has architecture knowledge** — scheduling decisions are backend-specific +3. **No premature optimization knobs** — don't add hints until we know we need them + +If the compiler has enough information (architecture + noise model), it should +make good scheduling decisions without programmer hints. If not, we can add +optional annotations later: + +```zlup +// Hypothetical future annotation (only if needed): +@schedule_hint(serialize) // "Run this gadget fast, don't spread resources" +fn syndrome_extraction(mut data: [n]qubit, mut ancilla: [m]qubit) -> [m]u1 { + // ... +} +``` + +But we should try to avoid this. The constraint-based model gives the compiler +freedom to schedule optimally. Adding hints constrains that freedom and burdens +the programmer. + +**Principle:** Let the compiler be smart. Add knobs only when proven necessary. + +### Connectivity Constraints + +Not all qubit pairs can interact directly. The compiler must: + +1. Respect hardware topology (coupling map) +2. Insert SWAP gates for non-adjacent interactions +3. Re-analyze parallelism after routing + +``` +Logical: cx (q[0], q[3]) <- May not be directly connected +Physical: swap q[1], q[2]; cx (q[0], q[1]); swap q[1], q[2] +``` + +### Execution Zones + +Some architectures have independent execution zones: + +``` +Zone A: qubits 0-15 +Zone B: qubits 16-31 + +Operations in different zones are naturally parallel. +Allocator assignment can optimize for zone boundaries. +``` + +### Timing Constraints + +Real hardware has gate timing constraints: + +- Different gates take different times +- Idle qubits may decohere +- Some parallelism is limited by control electronics + +The scheduler must balance parallelism against timing. + +## Connection to NASA Power of 10 + +The Power of 10 rules enable parallelism analysis: + +| Rule | How It Enables Parallelism | +|------|---------------------------| +| No unbounded loops | Loop iterations are finite → can unroll for analysis | +| No recursion | Call graph is a DAG → simpler interprocedural analysis | +| No dynamic allocation | All allocators known at compile time | +| Static dispatch | No runtime polymorphism to complicate analysis | +| Bounded complexity | Functions small enough for precise analysis | + +Without these constraints, parallelism analysis would require: +- Runtime profiling +- Conservative assumptions +- Complex pointer/alias analysis + +With them, we can do everything statically. + +## Design Decisions (Resolved) + +### Do we need explicit parallelism syntax? + +**No.** Parallelism is expressed through constraints already in the type system: + +- Function signature shows allocator access → shows what qubits it touches +- No allocator param = pure classical = parallel with any quantum op +- Different allocator params = different qubits = independent + +Adding `@parallel` would duplicate information already in the types. + +### Do we need explicit barriers? + +**No.** Scopes are explicit barriers: + +```zlup +{ + // This scope is a barrier + do_stuff(); +} +// Everything above completes before here +``` + +No scope = no enforced ordering = maximum parallelism. + +This is "explicit through constraints": the presence or absence of a scope +explicitly controls synchronization. No new syntax needed. + +### Is this "implicit" parallelism? + +**No.** It's constraint-based parallelism. The parallelism isn't hidden or +inferred—it's directly visible in the type signatures and scope structure. + +```zlup +fn decode(s: [m]u1) -> Corrections // ← This signature EXPLICITLY says "classical only" +``` + +The compiler doesn't guess. It reads the constraints you wrote. + +## Open Questions + +1. **Compiler architecture knowledge**: What information does the compiler need + about the target hardware? (Parallelism limits, noise model, connectivity) + +2. **Scheduling heuristics**: For constrained hardware, when should the compiler + serialize vs parallelize? (Decoherence vs. resource contention trade-off) + +3. **Do we need scheduling hints?**: Can the compiler make good decisions with + just architecture knowledge, or will we need programmer hints? (Start without, + add if proven necessary) + +4. **Allocator placement**: How should allocators be assigned to physical + qubits to maximize parallelism while respecting connectivity? + +5. **Syndrome history management**: What's the best pattern for decoders that + need sliding windows? (Value copy vs. borrowing vs. stateful decoder) + +6. **Real-time verification**: Should we add optional annotations to verify + timing constraints? (e.g., decoder must complete before next extraction) + +7. **Cross-backend portability**: If we add scheduling hints, can they be + portable across architectures or must they be backend-specific? + +## Future Work + +- [x] Implement allocator scope tracking in Zlup semantic analysis + - Done: `zlup::analysis::AllocatorAnalysis` +- [x] Add dependency graph construction pass + - Done: `zlup::analysis::DependencyGraph` +- [x] Prototype parallel layer extraction + - Done: `zlup::analysis::DependencyGraph::parallel_layers()` +- [x] Integrate analysis into compilation pipeline (CLI flags) + - Done: `zlup analyze` and `guppy-zlup analyze` commands + - Done: `--analyze` flag on compile commands +- [ ] Benchmark on representative quantum algorithms +- [ ] Explore integration with PECOS simulator for validation + +## Why This Works for Quantum + +Implicit parallelism is particularly well-suited for quantum computing: + +1. **Quantum operations are naturally pure** - A gate transforms its target qubits + and nothing else. No hidden state, no side effects. + +2. **Qubit identity is explicit** - Unlike classical memory (where pointers can alias), + qubits are always explicitly named. `q[0]` is unambiguously qubit 0 of allocator q. + +3. **Independence is common** - Quantum algorithms frequently apply the same operation + to many qubits (Hadamard on all qubits, etc.). These are trivially parallel. + +4. **Hardware wants parallelism** - Quantum hardware can naturally execute independent + gates simultaneously. The programming model should expose this, not hide it. + +5. **Coherence time pressure** - Faster execution = less decoherence. The compiler + should maximize parallelism automatically, not rely on programmer hints. + +## Summary + +Zlup is an experimental language exploring constraint-based parallelism: + +- **No threads** — ownership model makes them unnecessary +- **No locks** — no shared mutable state to protect +- **No `@parallel`** — type signatures already express parallelizability +- **No barrier syntax** — scopes are explicit sync points + +The key insight: **the function signature IS the parallelism declaration.** + +```zlup +fn decode(syndrome: [m]u1) -> Corrections // No allocators → classical only → parallel with any quantum +fn extract(mut q: [n]qubit) -> unit // Has allocator → needs qubit access → serialized with same allocator +``` + +This is: +- **Explicit** — constraints are visible in the type signature +- **Simple** — no new syntax to learn +- **Verifiable** — compiler enforces constraints, parallelism follows + +**For QEC specifically:** + +- Decoder signatures prove they can't touch qubits → safe overlap with extraction +- Classical/quantum boundary is explicit and compiler-verified +- Pipelining emerges from constraints, no concurrency primitives needed + +**Separation of concerns:** + +- **Language** (constraints) → expresses what CAN parallelize +- **Compiler** (scheduling) → decides what SHOULD parallelize given hardware + +The language stays simple. Architecture-specific optimization lives in the compiler. + +**Aligned with NASA Power of 10:** + +Don't add features to enable analysis. Remove possibilities until analysis is trivial. +The same constraints that make Zlup safe make parallelism obvious. + +## References + +- NASA Power of 10 Rules: https://spinroot.com/gerard/pdf/P10.pdf +- Quantum circuit scheduling: [various papers on ASAP/ALAP scheduling] +- QISKIT transpiler passes: routing, optimization, scheduling diff --git a/exp/guppy-zlup/docs/index.md b/exp/guppy-zlup/docs/index.md new file mode 100644 index 000000000..93570784a --- /dev/null +++ b/exp/guppy-zlup/docs/index.md @@ -0,0 +1,183 @@ +# guppy-zlup + +> **Note:** Zlup is an experimental toy language for exploring quantum programming +> language design concepts. This toolchain is for research and experimentation. + +A compiler toolchain for transforming Guppy quantum programs into Zlup, with +static analysis based on NASA's Power of 10 coding guidelines. + +## Overview + +guppy-zlup is a unified tool that: + +- **Validates** Guppy programs against safety rules (ZLUP001-010) +- **Analyzes** parallelism opportunities in compiled Zlup code +- **Emits** intermediate representation (IR) as JSON +- **Compiles** Guppy source or IR to Zlup source code + +``` +Guppy Source (.py) + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ check │──▶ Diagnostics (errors, warnings) +└─────────────┘ + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ emit │──▶ Guppy IR (.json) +└─────────────┘ + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ compile │ +└─────────────┘ + │ + ▼ +Zlup Source (.zlp) +``` + +## Quick Start + +### Installation + +```bash +cd exp/guppy-zlup +cargo build --features cli +``` + +### Basic Usage + +```bash +# Check a Guppy file for violations +guppy-zlup check program.py + +# Emit IR as JSON +guppy-zlup emit program.py -o program.json + +# Compile Guppy source directly to Zlup (lint + emit + compile) +guppy-zlup compile program.py -o program.zlp + +# Or compile from existing IR JSON +guppy-zlup compile --ir program.json -o program.zlp +``` + +### Example + +Given a Guppy program `bell.py`: + +```python +def bell() -> None: + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) + m = measure(q) + result("measurements", m) +``` + +Run the toolchain: + +```bash +$ guppy-zlup check bell.py +No issues found. +All checks passed! + +$ guppy-zlup compile bell.py --stdout +fn bell() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + m := mz([2]u1) q; + result("measurements", m); + return; +} +``` + +## CLI Reference + +### `guppy-zlup check` + +Validate a Guppy file against lint rules. + +```bash +guppy-zlup check ... [OPTIONS] + +Options: + -W, --warnings-as-errors Treat warnings as errors + --config Path to config file (pyproject.toml) + -D, --disable Disable specific rules (can be repeated) + --max-complexity Maximum complexity for ZLUP007 + -f, --format Output format: text (default), json, or sarif + -w, --watch Watch for file changes and re-lint +``` + +### `guppy-zlup emit` + +Emit validated IR as JSON. + +```bash +guppy-zlup emit [OPTIONS] + +Options: + -o, --output Output file (default: ir.json) + --skip-lint Skip lint check + --stdout Print to stdout instead of file +``` + +### `guppy-zlup compile` + +Compile Guppy source or IR to Zlup. + +```bash +guppy-zlup compile [OPTIONS] + +Options: + -o, --output Output file (default: .zlp) + --stdout Print to stdout instead of file + --ir Input is IR JSON (skip linting) + --validate Validate with guppylang before compiling (requires Python) + --analyze Run parallelism analysis after compilation +``` + +### `guppy-zlup analyze` + +Analyze parallelism opportunities in generated Zlup code. + +```bash +guppy-zlup analyze [OPTIONS] + +Options: + --ir Input is IR JSON (skip linting) + -f, --format Output format: text (default) or json + -v, --verbose Show detailed dependency information +``` + +## Documentation + +- [Architecture](./architecture.md) - Pipeline design, AST layers, internals +- [Lint Rules](./rules.md) - ZLUP001-010 reference with examples +- [IR Format](./ir-format.md) - Guppy IR JSON schema +- [Examples](./examples/bell-state.md) - Walkthrough of common patterns + +### Future / Design Notes + +- [Parallelism](./future/parallelism.md) - Scope-aware parallelism analysis + +## Why These Rules? + +Quantum programs run on hardware with strict constraints: + +1. **Bounded execution** - No infinite loops; hardware has finite coherence time +2. **Static resource allocation** - Qubit counts must be known at compile time +3. **Predictable control flow** - Dynamic dispatch complicates timing analysis +4. **Type safety** - Quantum operations require precise type information + +The lint rules (ZLUP001-010) enforce these constraints at the source level, +catching violations before they reach the quantum hardware. + +## License + +Apache-2.0 diff --git a/exp/guppy-zlup/docs/ir-format.md b/exp/guppy-zlup/docs/ir-format.md new file mode 100644 index 000000000..596f52520 --- /dev/null +++ b/exp/guppy-zlup/docs/ir-format.md @@ -0,0 +1,414 @@ +# Guppy IR Format + +The Guppy IR (Intermediate Representation) is a JSON format that represents +validated Guppy programs. It enables tool interoperability and caching of +validated programs. + +## Schema Overview + +```json +{ + "version": "0.1.0", + "source_file": "path/to/source.py", + "functions": [ + { + "name": "function_name", + "params": [...], + "return_type": {...}, + "body": [...], + "location": {...} + } + ] +} +``` + +## Top-Level Fields + +| Field | Type | Required | Description | +|--------------|------------|----------|---------------------------------| +| `version` | string | Yes | IR schema version ("0.1.0") | +| `source_file`| string | No | Original source file path | +| `functions` | Function[] | Yes | List of function definitions | + +## Function + +```json +{ + "name": "bell_state", + "params": [ + {"name": "n", "type": {"kind": "primitive", "name": "int"}} + ], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [...], + "is_pub": true, + "location": {"line": 1, "column": 1, "end_line": 10, "end_column": 1} +} +``` + +| Field | Type | Required | Description | +|---------------|------------|----------|--------------------------------| +| `name` | string | Yes | Function name | +| `params` | Param[] | No | Function parameters | +| `return_type` | TypeExpr | No | Return type annotation | +| `body` | Stmt[] | Yes | Function body statements | +| `is_pub` | boolean | No | Whether function is public | +| `location` | Location | No | Source location | + +## Parameter + +```json +{"name": "q", "type": {"kind": "qalloc", "size": {"kind": "literal", "value": 4}}} +``` + +| Field | Type | Required | Description | +|--------|----------|----------|----------------------| +| `name` | string | Yes | Parameter name | +| `type` | TypeExpr | No | Type annotation | + +## Type Expressions + +### Primitive Types + +```json +{"kind": "primitive", "name": "int"} +{"kind": "primitive", "name": "float"} +{"kind": "primitive", "name": "bool"} +{"kind": "primitive", "name": "str"} +{"kind": "primitive", "name": "None"} +``` + +### Qubit Allocation Type + +```json +{"kind": "qalloc", "size": {"kind": "literal", "value": 4}} +{"kind": "qalloc", "size": {"kind": "ident", "name": "n"}} +``` + +### Array Type + +```json +{"kind": "array", "element": {"kind": "primitive", "name": "int"}} +``` + +### Optional Type + +```json +{"kind": "optional", "element": {"kind": "primitive", "name": "int"}} +``` + +### Named Type + +```json +{"kind": "named", "name": "MyCustomType"} +``` + +## Statements + +### Qubit Allocation + +```json +{ + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 4} +} +``` + +Represents `q = qubit[4]`. + +### Gate Application + +```json +{ + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] +} +``` + +Represents `h(q[0])`. + +Supported gates: `h`, `x`, `y`, `z`, `t`, `tdg`, `sx`, `sy`, `sz`, `szdg`, +`rx`, `ry`, `rz`, `cx`, `cy`, `cz`, `swap`, `iswap`, `ccx`, `pz` + +> **Note:** Gate names follow PECOS conventions. Use `sz` (S-gate, sqrt of Z) and `szdg` (S-dagger) rather than `s`/`sdg`. + +For multi-qubit gates: + +```json +{ + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] +} +``` + +### Measurement + +As a statement (result discarded): +```json +{ + "kind": "measure", + "target": {"kind": "ident", "name": "q"} +} +``` + +As an assignment (result captured): +```json +{ + "kind": "assign", + "target": {"kind": "ident", "name": "m"}, + "value": { + "kind": "call", + "callee": "measure", + "args": [{"kind": "ident", "name": "q"}] + } +} +``` + +**Note on Zlup measurement syntax:** + +The generated Zlup supports flexible measurement syntax: + +```zlup +// Per-qubit measurement into array +m := mz([4]u1) q; + +// Single qubit measurement +bit := mz(u1) q[0]; + +// Pack into integer type +syndrome_byte := mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; + +// Pack into custom struct type +Syndrome := struct { x_parity: u1, z_parity: u1, flags: u2 }; +syndrome := mz(pack Syndrome) [ancilla[0], ancilla[1], ancilla[2], ancilla[3]]; +``` + +The `pack` modifier fills bits sequentially into the target type's bit layout (LSB first). + +### Assignment + +```json +{ + "kind": "assign", + "target": {"kind": "ident", "name": "x"}, + "value": {"kind": "literal", "value": 42} +} +``` + +Target kinds: `ident`, `index`, `attr`, `tuple` + +### For Loop + +```json +{ + "kind": "for", + "var": "i", + "range": { + "start": {"kind": "literal", "value": 0}, + "end": {"kind": "ident", "name": "n"} + }, + "body": [...] +} +``` + +### If Statement + +```json +{ + "kind": "if", + "condition": {"kind": "binary", "left": ..., "op": "==", "right": ...}, + "then_body": [...], + "else_body": [...] +} +``` + +### While Loop + +```json +{ + "kind": "while", + "condition": {"kind": "binary", ...}, + "body": [...] +} +``` + +### Return + +```json +{ + "kind": "return", + "return_value": {"kind": "ident", "name": "result"} +} +``` + +For entry/main functions, use `result()` instead of returning values. + +### Result Emission + +```json +{ + "kind": "result", + "tag": "measurements", + "value": {"kind": "ident", "name": "m"} +} +``` + +Represents `result("measurements", m)` - emits a tagged value to the quantum runtime. +Entry/main functions should use explicit `result()` calls rather than returning values. + +## Expressions + +### Literals + +```json +{"kind": "literal", "value": 42} +{"kind": "literal", "value": 3.14} +{"kind": "literal", "value": "hello"} +{"kind": "literal", "value": true} +{"kind": "literal", "value": null} +``` + +### Identifier + +```json +{"kind": "ident", "name": "variable_name"} +``` + +### Index Access + +```json +{ + "kind": "index", + "array": "q", + "index": {"kind": "literal", "value": 0} +} +``` + +Or with expression base: +```json +{ + "kind": "index", + "value": {"kind": "ident", "name": "arr"}, + "index": {"kind": "ident", "name": "i"} +} +``` + +### Binary Operations + +```json +{ + "kind": "binary", + "left": {"kind": "ident", "name": "a"}, + "op": "+", + "right": {"kind": "literal", "value": 1} +} +``` + +Arithmetic operators: `+`, `-`, `*`, `/`, `//`, `%`, `**`, `<<`, `>>`, `|`, `^`, `&`, `@` + +Comparison operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `is`, `is not`, `in`, `not in` + +Both arithmetic and comparison operations use `"kind": "binary"`: + +```json +{ + "kind": "binary", + "left": {"kind": "ident", "name": "x"}, + "op": "<", + "right": {"kind": "literal", "value": 10} +} + +### Function Call + +```json +{ + "kind": "call", + "callee": "function_name", + "args": [ + {"kind": "ident", "name": "arg1"}, + {"kind": "literal", "value": 42} + ] +} +``` + +Or with expression callee: +```json +{ + "kind": "call", + "func": {"kind": "field", "object": {...}, "field": "method"}, + "args": [...] +} +``` + +## Location + +```json +{ + "line": 1, + "column": 1, + "end_line": 5, + "end_column": 10 +} +``` + +All fields are 1-indexed. + +## Complete Example + +```json +{ + "version": "0.1.0", + "source_file": "bell.py", + "functions": [ + { + "name": "bell_state", + "params": [], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + }, + { + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] + }, + { + "kind": "assign", + "target": {"kind": "ident", "name": "m"}, + "value": { + "kind": "call", + "callee": "measure", + "args": [{"kind": "ident", "name": "q"}] + } + }, + { + "kind": "result", + "tag": "measurements", + "value": {"kind": "ident", "name": "m"} + } + ], + "location": {"line": 1, "column": 1, "end_line": 6, "end_column": 13} + } + ] +} +``` diff --git a/exp/guppy-zlup/docs/rules.md b/exp/guppy-zlup/docs/rules.md new file mode 100644 index 000000000..c71616b5c --- /dev/null +++ b/exp/guppy-zlup/docs/rules.md @@ -0,0 +1,534 @@ +# Lint Rules Reference + +The Guppy linter enforces rules derived from NASA's Power of 10 coding +guidelines, adapted for quantum computing. These rules ensure programs are +safe, predictable, and suitable for execution on quantum hardware. + +## Rule Summary + +| Rule | Severity | Description | +|---------|----------|--------------------------------------| +| ZLUP001 | Error | Unbounded loops | +| ZLUP002 | Error | Recursive function calls | +| ZLUP003 | Error | Dynamic memory allocation in loops | +| ZLUP004 | Error | Dynamic dispatch | +| ZLUP005 | Warning | Unchecked error-prone operations | +| ZLUP006 | Warning | Missing type annotations | +| ZLUP007 | Warning | Excessive control flow complexity | +| ZLUP008 | Warning | Deep call nesting (>4 levels) | +| ZLUP009 | Info | Missing assertions in large functions| +| ZLUP010 | Warning | Global mutable state | + +--- + +## ZLUP001: Unbounded Loops + +**Severity:** Error + +**Rationale:** Quantum hardware has finite coherence time. Programs must +terminate within a bounded time, which requires all loops to have statically +determinable bounds. + +### Triggers + +- `while True:` loops +- `while 1:` loops +- While loops without a clear termination condition + +### Examples + +```python +# BAD: Unbounded loop +def bad(): + while True: # ZLUP001: unbounded loop + do_something() + + +# BAD: Effectively unbounded +def also_bad(): + while some_condition(): # ZLUP001: cannot prove termination + do_something() + + +# GOOD: Bounded by break with finite iterations +def acceptable(): + for i in range(100): + if done(): + break + do_something() + + +# GOOD: Clear termination condition +def good(): + for i in range(n): # Bounded by n + do_something() +``` + +### Fix + +Replace unbounded loops with bounded `for` loops or add explicit iteration limits. + +--- + +## ZLUP002: Recursion + +**Severity:** Error + +**Rationale:** Recursive calls can lead to unbounded stack growth and +unpredictable execution time. Quantum programs must have statically +determinable resource usage. + +### Triggers + +- Direct recursion (function calls itself) +- Indirect recursion (A calls B, B calls A) + +### Examples + +```python +# BAD: Direct recursion +def factorial(n: int) -> int: + if n <= 1: + return 1 + return n * factorial(n - 1) # ZLUP002: recursive call + + +# BAD: Indirect recursion +def ping(n: int) -> int: + if n <= 0: + return 0 + return pong(n - 1) # ZLUP002: mutual recursion + + +def pong(n: int) -> int: + return ping(n - 1) # ZLUP002: mutual recursion + + +# GOOD: Iterative version +def factorial(n: int) -> int: + result = 1 + for i in range(1, n + 1): + result *= i + return result +``` + +### Fix + +Convert recursive algorithms to iterative versions using explicit loops. + +--- + +## ZLUP003: Dynamic Allocation in Loops + +**Severity:** Error + +**Rationale:** Dynamic memory allocation inside loops can lead to unbounded +memory growth. For quantum programs, qubit allocation must be statically +determinable. + +### Triggers + +- `qubit[n]` allocation inside loop bodies +- List creation (`[]`, `list()`) inside loops +- `.append()` calls inside loops +- List comprehensions that grow unboundedly + +### Examples + +```python +# BAD: Qubit allocation in loop +def bad(): + for i in range(10): + q = qubit[2] # ZLUP003: allocation in loop + h(q[0]) + + +# BAD: List growth in loop +def also_bad(): + results = [] + for i in range(n): + results.append(measure(q)) # ZLUP003: dynamic growth + + +# GOOD: Pre-allocate outside loop +def good(): + q = qubit[20] # Allocate once + for i in range(10): + h(q[i * 2]) + + +# GOOD: Fixed-size collection +def also_good(n: int): + q = qubit[n] + results = [0] * n # Pre-sized + for i in range(n): + results[i] = measure(q[i]) +``` + +### Fix + +Move allocations outside loops. Pre-size collections before loop entry. + +--- + +## ZLUP004: Dynamic Dispatch + +**Severity:** Error + +**Rationale:** Dynamic dispatch (runtime method resolution) makes control flow +unpredictable and complicates timing analysis on quantum hardware. + +### Triggers + +- `getattr()` calls +- `eval()` calls +- `exec()` calls +- Subscript-based function calls (`funcs[i]()`) + +### Examples + +```python +# BAD: Dynamic attribute access +def bad(obj, method_name: str): + func = getattr(obj, method_name) # ZLUP004: dynamic dispatch + func() + + +# BAD: eval +def also_bad(code: str): + eval(code) # ZLUP004: eval + + +# BAD: Subscript dispatch +def dispatch(funcs, i: int): + funcs[i]() # ZLUP004: dynamic dispatch + + +# GOOD: Static dispatch +def good(obj): + obj.known_method() # Static, known at compile time + + +# GOOD: Match/if for dispatch +def also_good(choice: int): + if choice == 0: + func_a() + elif choice == 1: + func_b() +``` + +### Fix + +Use static method calls or explicit if/match statements for dispatch. + +--- + +## ZLUP005: Unchecked Error-Prone Operations + +**Severity:** Warning + +**Rationale:** Operations that can fail at runtime should be wrapped in +error handling to prevent unexpected crashes. + +### Triggers + +- Division operations not in try/except blocks +- `int()` conversions not in try/except blocks +- Other operations that may raise exceptions + +### Examples + +```python +# WARNING: Division might fail +def bad(a: int, b: int) -> int: + return a / b # ZLUP005: possible division by zero + + +# OK: Division by literal +def ok(a: int) -> int: + return a / 2 # Literal cannot be zero + + +# GOOD: Wrapped in try/except +def good(a: int, b: int) -> int: + try: + return a / b + except ZeroDivisionError: + return 0 + + +# GOOD: Explicit check +def also_good(a: int, b: int) -> int: + if b == 0: + return 0 + return a / b +``` + +### Fix + +Wrap error-prone operations in try/except or add explicit validation. + +--- + +## ZLUP006: Missing Type Annotations + +**Severity:** Warning + +**Rationale:** Type annotations enable static analysis and catch errors at +compile time rather than runtime. They're especially important for quantum +programs where runtime debugging is difficult. + +### Triggers + +- Functions without return type annotations +- Parameters without type annotations +- Exception: `self` parameter (implicitly typed) + +### Examples + +```python +# WARNING: Missing return type +def bad(x: int): # ZLUP006: missing return type + return x + 1 + + +# WARNING: Missing parameter type +def also_bad(x) -> int: # ZLUP006: missing parameter type + return x + 1 + + +# GOOD: Fully annotated +def good(x: int) -> int: + return x + 1 + + +# OK: self doesn't need annotation +class Foo: + def method(self, x: int) -> int: # self is fine + return x +``` + +### Fix + +Add type annotations to all function parameters and return types. + +--- + +## ZLUP007: Excessive Control Flow Complexity + +**Severity:** Warning + +**Rationale:** Complex control flow is harder to analyze, test, and reason +about. High cyclomatic complexity correlates with bugs. + +### Triggers + +- Functions with cyclomatic complexity > 10 (configurable) +- Deeply nested control structures + +### Complexity Calculation + +Each of the following adds 1 to complexity: +- `if` statement +- `elif` clause +- `for` loop +- `while` loop +- `except` handler +- `and` operator +- `or` operator +- Conditional expression (`x if cond else y`) +- Comprehension with `if` clause + +### Examples + +```python +# WARNING: Too complex +def bad(x: int, y: int, z: int) -> int: + if x > 0: + if y > 0: + if z > 0: + return 1 + else: + if x > y: + return 2 + else: + return 3 + else: + for i in range(x): + if i > y: + return 4 + # ... continues with more nesting + # ZLUP007: complexity > 10 + + +# GOOD: Refactored into smaller functions +def handle_positive_z(x: int, y: int) -> int: + return 2 if x > y else 3 + + +def handle_positive_y(x: int, y: int, z: int) -> int: + if z > 0: + return 1 + return handle_positive_z(x, y) + + +def good(x: int, y: int, z: int) -> int: + if x <= 0: + return 0 + if y > 0: + return handle_positive_y(x, y, z) + return handle_negative_y(x, y) +``` + +### Fix + +Break complex functions into smaller, focused helper functions. + +--- + +## ZLUP008: Deep Call Nesting + +**Severity:** Warning + +**Rationale:** Deeply nested function calls (calls within calls within calls) +make code hard to verify and debug. NASA Power of 10 Rule 5 recommends keeping +assertions and expressions simple and verifiable. + +### Triggers + +- Function calls nested more than 4 levels deep (configurable) +- Chains like `a(b(c(d(e()))))` + +### Examples + +```python +# WARNING: Call depth exceeds 4 +def bad(): + result = a(b(c(d(e())))) # ZLUP008: too deep + + +# GOOD: Intermediate variables +def good(): + e_result = e() + d_result = d(e_result) + c_result = c(d_result) + b_result = b(c_result) + result = a(b_result) +``` + +### Fix + +Extract nested calls into named intermediate variables. This improves +readability, debuggability, and makes assertions on intermediate values possible. + +--- + +## ZLUP009: Missing Assertions + +**Severity:** Info + +**Rationale:** NASA Power of 10 Rule 5 requires assertions to check invariants. +Non-trivial functions should contain assertions to validate preconditions, +postconditions, and invariants. + +### Triggers + +- Functions with 5+ statements that contain no `assert` statements +- Exception: test functions (`test_*`) and dunder methods (`__*__`) + +### Examples + +```python +# INFO: Function has 6 statements but no assertions +def process(data): + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b # ZLUP009: no assertions + + +# GOOD: Has assertion for precondition +def process(data): + assert data is not None, "data must not be None" + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b +``` + +### Fix + +Add `assert` statements to validate preconditions, postconditions, or +loop invariants. Even simple assertions like `assert data is not None` +help document assumptions and catch errors early. + +--- + +## ZLUP010: Global Mutable State + +**Severity:** Warning + +**Rationale:** Mutable global state creates hidden dependencies between +functions, makes programs harder to reason about, and can cause issues +in concurrent execution (relevant for quantum-classical hybrid programs). + +### Triggers + +- Module-level variable assignments (lowercase names) +- Use of `global` keyword inside functions +- Exception: UPPER_CASE constants are allowed + +### Examples + +```python +# WARNING: Mutable global state +counter = 0 # ZLUP010: lowercase module-level variable + + +def increment(): + global counter # ZLUP010: global keyword + counter += 1 + + +# GOOD: Constants allowed +MAX_SIZE = 100 +DEFAULT_VALUE = 42 + + +# GOOD: Pass state explicitly +def increment(counter: int) -> int: + return counter + 1 +``` + +### Fix + +Use UPPER_CASE names for true constants. For mutable state, pass values +explicitly through function parameters and return values. + +--- + +## Configuration + +Rules can be configured in `pyproject.toml`: + +```toml +[tool.guppy-zlup] +# Disable specific rules +disabled_rules = ["ZLUP005"] + +# Adjust complexity threshold +max_complexity = 15 + +# Treat warnings as errors +warnings_as_errors = true +``` + +Or via command line: + +```bash +guppy-zlup check program.py --disable ZLUP005 --max-complexity 15 +``` diff --git a/exp/guppy-zlup/ir.json b/exp/guppy-zlup/ir.json new file mode 100644 index 000000000..ba6cd394a --- /dev/null +++ b/exp/guppy-zlup/ir.json @@ -0,0 +1,96 @@ +{ + "version": "0.1.0", + "functions": [ + { + "name": "nested_cond", + "params": [ + { + "name": "a", + "type": { + "kind": "primitive", + "name": "int" + } + }, + { + "name": "b", + "type": { + "kind": "primitive", + "name": "int" + } + } + ], + "return_type": { + "kind": "primitive", + "name": "int" + }, + "body": [ + { + "kind": "if", + "condition": { + "kind": "binary", + "op": "gt", + "left": { + "kind": "ident", + "name": "a" + }, + "right": { + "kind": "literal", + "value": 0 + } + }, + "then_body": [ + { + "kind": "if", + "condition": { + "kind": "binary", + "op": "gt", + "left": { + "kind": "ident", + "name": "b" + }, + "right": { + "kind": "literal", + "value": 0 + } + }, + "then_body": [ + { + "kind": "return", + "return_value": { + "kind": "literal", + "value": 1 + } + } + ], + "else_body": [ + { + "kind": "return", + "return_value": { + "kind": "literal", + "value": 2 + } + } + ] + } + ], + "else_body": [ + { + "kind": "return", + "return_value": { + "kind": "literal", + "value": 3 + } + } + ] + } + ], + "location": { + "line": 1, + "column": 1, + "end_line": 8, + "end_column": 17 + } + } + ], + "source_file": "/tmp/test_nested_if.py" +} diff --git a/exp/guppy-zlup/mkdocs.yml b/exp/guppy-zlup/mkdocs.yml new file mode 100644 index 000000000..d325425df --- /dev/null +++ b/exp/guppy-zlup/mkdocs.yml @@ -0,0 +1,68 @@ +site_name: guppy-zlup +site_description: Guppy linter and Zlup compiler +site_url: https://pecos-packages.github.io/PECOS/guppy-zlup/ + +repo_name: PECOS-packages/PECOS +repo_url: https://github.com/PECOS-packages/PECOS + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.instant + - navigation.sections + - navigation.expand + - navigation.top + - content.code.copy + - content.code.annotate + icon: + repo: fontawesome/brands/github + +nav: + - Home: index.md + - Architecture: architecture.md + - Lint Rules: rules.md + - IR Format: ir-format.md + - Examples: + - Bell State: examples/bell-state.md + - Grover's Algorithm: examples/grover.md + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - tables + - admonition + - pymdownx.details + - attr_list + - md_in_html + - toc: + permalink: true + +plugins: + - search + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/PECOS-packages/PECOS diff --git a/exp/guppy-zlup/scripts/validate_guppy.py b/exp/guppy-zlup/scripts/validate_guppy.py new file mode 100755 index 000000000..9747b42c6 --- /dev/null +++ b/exp/guppy-zlup/scripts/validate_guppy.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Validate Guppy source files using guppylang. + +This script validates that Guppy source code is semantically correct +by attempting to compile it with guppylang. + +Usage: + python validate_guppy.py + +Exit codes: + 0 - Valid Guppy code + 1 - Invalid Guppy code (errors printed to stderr) + 2 - File not found or other IO error +""" + +import importlib.util +import json +import sys +from pathlib import Path + + +def validate_guppy_file(filepath: str) -> tuple[bool, list[dict]]: + """Validate a Guppy source file using guppylang. + + Returns: + (is_valid, errors) where errors is a list of error dicts + """ + path = Path(filepath) + if not path.exists(): + return False, [{"error": "FileNotFound", "message": f"File not found: {filepath}"}] + + try: + spec = importlib.util.spec_from_file_location("guppy_module", str(path)) + if spec is None or spec.loader is None: + return False, [{"error": "ImportError", "message": "Could not load module"}] + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Find guppy functions and compile them + compiled = [] + for name in dir(module): + obj = getattr(module, name) + if hasattr(obj, "compile") and hasattr(obj, "check"): + # This is a GuppyFunctionDefinition + obj.compile() + compiled.append(name) + + if not compiled: + # No guppy functions found - just syntax checking passed + return True, [] + + except Exception as e: + error_type = type(e).__name__ + error_msg = str(e) + + # Try to extract useful info from guppy errors + error_info = { + "error": error_type, + "message": error_msg, + } + + # Parse guppy error details if available + if "var=" in error_msg: + import re + + match = re.search(r"var='(\w+)'", error_msg) + if match: + error_info["variable"] = match.group(1) + + return False, [error_info] + else: + return True, [] + + +def main(): + if len(sys.argv) < 2: + print("Usage: validate_guppy.py ", file=sys.stderr) + sys.exit(2) + + filepath = sys.argv[1] + output_json = "--json" in sys.argv + + is_valid, errors = validate_guppy_file(filepath) + + if output_json: + result = { + "valid": is_valid, + "errors": errors, + "file": filepath, + } + print(json.dumps(result)) + else: + if is_valid: + print(f"Valid: {filepath}") + else: + print(f"Invalid: {filepath}", file=sys.stderr) + for err in errors: + print(f" {err['error']}: {err['message'][:200]}", file=sys.stderr) + + sys.exit(0 if is_valid else 1) + + +if __name__ == "__main__": + main() diff --git a/exp/guppy-zlup/src/compiler.rs b/exp/guppy-zlup/src/compiler.rs new file mode 100644 index 000000000..3550b7472 --- /dev/null +++ b/exp/guppy-zlup/src/compiler.rs @@ -0,0 +1,9 @@ +//! Guppy IR to Zlup compiler module. +//! +//! Transforms validated Guppy IR into Zlup source code. + +pub mod parser; +pub mod transform; + +pub use parser::{ParseError, parse_ir}; +pub use transform::{TransformError, transform}; diff --git a/exp/guppy-zlup/src/compiler/parser.rs b/exp/guppy-zlup/src/compiler/parser.rs new file mode 100644 index 000000000..4e6797a51 --- /dev/null +++ b/exp/guppy-zlup/src/compiler/parser.rs @@ -0,0 +1,64 @@ +//! JSON IR parser. + +use crate::ir::GuppyIR; + +/// Parse Guppy IR from JSON string. +pub fn parse_ir(json: &str) -> Result { + serde_json::from_str(json).map_err(ParseError::Json) +} + +/// Parse Guppy IR from a file. +pub fn parse_ir_file(path: &str) -> Result { + let json = std::fs::read_to_string(path).map_err(ParseError::Io)?; + parse_ir(&json) +} + +/// Parse error. +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON parse error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Invalid IR version: expected {expected}, got {actual}")] + InvalidVersion { expected: String, actual: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal() { + let json = r#"{"version": "0.1.0", "functions": []}"#; + let ir = parse_ir(json).unwrap(); + assert_eq!(ir.version, "0.1.0"); + assert!(ir.functions.is_empty()); + } + + #[test] + fn test_parse_function() { + let json = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test", + "params": [], + "body": [] + } + ] + }"#; + let ir = parse_ir(json).unwrap(); + assert_eq!(ir.functions.len(), 1); + assert_eq!(ir.functions[0].name, "test"); + } + + #[test] + fn test_parse_invalid_json() { + let json = "not valid json"; + let result = parse_ir(json); + assert!(result.is_err()); + } +} diff --git a/exp/guppy-zlup/src/compiler/transform.rs b/exp/guppy-zlup/src/compiler/transform.rs new file mode 100644 index 000000000..9b4f53648 --- /dev/null +++ b/exp/guppy-zlup/src/compiler/transform.rs @@ -0,0 +1,1081 @@ +//! Transform Guppy IR to Zlup AST. + +use std::collections::BTreeMap; + +use zlup::ast::{self as zlup_ast, GateKind as ZlupGateKind}; + +use crate::ir::{Expr, ExprKind, Function, GateKind, GuppyIR, Param, Stmt, StmtKind, TypeExpr}; + +/// Transform context for tracking variable information with invariant checking. +#[derive(Default)] +struct TransformContext { + /// Maps qalloc variable names to their sizes (if known). + qalloc_sizes: BTreeMap, + /// Set of variable names that have been declared (for distinguishing new vars from reassignments). + declared_vars: std::collections::BTreeSet, + /// Track which allocators have been used in gates (for invariant checking). + #[cfg(debug_assertions)] + used_allocators: std::collections::BTreeSet, +} + +impl TransformContext { + /// Register a new qalloc. + fn register_qalloc(&mut self, name: &str, size: i128) { + debug_assert!( + !self.qalloc_sizes.contains_key(name), + "Invariant violation: duplicate qalloc for '{}'", + name + ); + self.qalloc_sizes.insert(name.to_string(), size); + self.declared_vars.insert(name.to_string()); + } + + /// Register a variable declaration. + fn declare_var(&mut self, name: &str) { + self.declared_vars.insert(name.to_string()); + } + + /// Check if a variable is declared. + fn is_declared(&self, name: &str) -> bool { + self.declared_vars.contains(name) + } + + /// Get qalloc size, with invariant check. + fn get_qalloc_size(&self, name: &str) -> Option { + let size = self.qalloc_sizes.get(name).copied(); + #[cfg(debug_assertions)] + if size.is_some() { + debug_assert!( + self.declared_vars.contains(name), + "Invariant violation: qalloc '{}' in qalloc_sizes but not in declared_vars", + name + ); + } + size + } + + /// Mark an allocator as used (for gates). + #[cfg(debug_assertions)] + fn mark_allocator_used(&mut self, name: &str) { + debug_assert!( + self.qalloc_sizes.contains_key(name), + "Invariant violation: gate uses allocator '{}' before it was allocated", + name + ); + self.used_allocators.insert(name.to_string()); + } + + #[cfg(not(debug_assertions))] + fn mark_allocator_used(&mut self, _name: &str) { + // No-op in release builds + } +} + +/// Transform Guppy IR to Zlup AST. +pub fn transform(ir: &GuppyIR) -> Result { + let mut declarations = Vec::new(); + + for func in &ir.functions { + let decl = transform_function(func)?; + declarations.push(zlup_ast::TopLevelDecl::Fn(decl)); + } + + Ok(zlup_ast::Program { + name: ir.source_file.clone().unwrap_or_else(|| "main".to_string()), + declarations, + location: None, + }) +} + +fn transform_function(func: &Function) -> Result { + let params = func + .params + .iter() + .map(transform_param) + .collect::, _>>()?; + + let return_type = func.return_type.as_ref().map(transform_type); + + let mut ctx = TransformContext::default(); + // Add function parameters to declared_vars + for param in &func.params { + ctx.declare_var(¶m.name); + } + let mut body = transform_block_with_ctx(&func.body, &mut ctx)?; + + // Check if the function returns unit and needs an explicit return statement. + // Zlup requires explicit `return unit;` for functions that return unit. + let is_unit_return = match &return_type { + None => true, + Some(zlup_ast::TypeExpr::Unit) => true, + Some(zlup_ast::TypeExpr::Named(path)) if path.segments == ["None"] => true, + _ => false, + }; + + // Check if the last statement is already a return + let has_trailing_return = body + .statements + .last() + .is_some_and(|stmt| matches!(stmt, zlup_ast::Stmt::Return(_))); + + // Add implicit return if needed (return; is equivalent to return unit; in Zlup) + if is_unit_return && !has_trailing_return { + body.statements + .push(zlup_ast::Stmt::Return(zlup_ast::ReturnStmt { + value: None, + location: None, + })); + } + + Ok(zlup_ast::FnDecl { + name: func.name.clone(), + params, + return_type, + body, + is_pub: func.is_pub.unwrap_or(false), + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }) +} + +fn transform_param(param: &Param) -> Result { + Ok(zlup_ast::Param { + name: param.name.clone(), + ty: transform_type(¶m.ty), + is_comptime: false, + location: None, + }) +} + +fn transform_type(ty: &TypeExpr) -> zlup_ast::TypeExpr { + match ty.kind.as_str() { + "primitive" => { + let name = ty.name.as_deref().unwrap_or("unknown"); + match name { + "int" => zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::IInt { bits: 64 }), + "float" => zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::F64), + "bool" => zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::Bool), + "None" => zlup_ast::TypeExpr::Unit, + _ => zlup_ast::TypeExpr::Named(zlup_ast::TypePath { + segments: vec![name.to_string()], + location: None, + }), + } + } + "qalloc" => { + zlup_ast::TypeExpr::QAlloc(ty.size.as_ref().map(|s| Box::new(transform_expr(s)))) + } + "array" => { + let element = ty + .element + .as_ref() + .map(|e| transform_type(e)) + .unwrap_or(zlup_ast::TypeExpr::Unit); + let size = ty.size.as_ref().map(|s| transform_expr(s)); + zlup_ast::TypeExpr::Array(Box::new(zlup_ast::ArrayType { + element, + size, + sentinel: None, + })) + } + "optional" => { + let element = ty + .element + .as_ref() + .map(|e| transform_type(e)) + .unwrap_or(zlup_ast::TypeExpr::Unit); + zlup_ast::TypeExpr::Optional(Box::new(element)) + } + "tuple" => { + // In quantum code, tuple[bool, ...] typically contains measurement results + // which are u1 in Zlup, so map bool->u1 within tuples + let elements: Vec = ty + .elements + .iter() + .map(|elem| { + if elem.kind == "primitive" && elem.name.as_deref() == Some("bool") { + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }) + } else { + transform_type(elem) + } + }) + .collect(); + zlup_ast::TypeExpr::Tuple(elements) + } + "named" => { + let name = ty.name.as_deref().unwrap_or("unknown"); + zlup_ast::TypeExpr::Named(zlup_ast::TypePath { + segments: vec![name.to_string()], + location: None, + }) + } + _ => zlup_ast::TypeExpr::Unit, + } +} + +fn transform_block(stmts: &[Stmt]) -> Result { + let mut ctx = TransformContext::default(); + transform_block_with_ctx(stmts, &mut ctx) +} + +fn transform_block_with_ctx( + stmts: &[Stmt], + ctx: &mut TransformContext, +) -> Result { + let mut statements = Vec::new(); + + for stmt in stmts { + let transformed = transform_stmt_with_ctx(stmt, ctx)?; + statements.extend(transformed); + } + + Ok(zlup_ast::Block { + label: None, + attrs: Vec::new(), + statements, + trailing_expr: None, + location: None, + }) +} + +fn transform_stmt(stmt: &Stmt) -> Result, TransformError> { + let mut ctx = TransformContext::default(); + transform_stmt_with_ctx(stmt, &mut ctx) +} + +fn transform_stmt_with_ctx( + stmt: &Stmt, + ctx: &mut TransformContext, +) -> Result, TransformError> { + match stmt.kind { + StmtKind::Qalloc => { + let name = stmt + .name + .as_ref() + .ok_or(TransformError::MissingField("name"))?; + + // Extract the size value for tracking + let size_value = stmt.size.as_ref().and_then(|s| { + if s.kind == ExprKind::Literal { + s.value.as_ref().and_then(|v| v.as_i64()).map(|i| i as i128) + } else { + None + } + }); + + // Track the qalloc size and register variable using context methods + ctx.register_qalloc(name, size_value.unwrap_or(1)); + + let size = stmt.size.as_ref().map(transform_expr).unwrap_or_else(|| { + zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 1, + suffix: None, + location: None, + }) + }); + + // Create a binding with qalloc(N) call as the value + // In Zlup: `mut q := qalloc(N);` + let qalloc_call = zlup_ast::Expr::Call(Box::new(zlup_ast::CallExpr { + callee: zlup_ast::Expr::Ident(zlup_ast::Ident { + name: "qalloc".to_string(), + location: None, + }), + args: vec![size], + location: None, + })); + + Ok(vec![zlup_ast::Stmt::Binding(zlup_ast::Binding { + name: name.clone(), + ty: None, + value: Some(qalloc_call), + is_mutable: true, + is_pub: false, + doc_comment: None, + location: None, + })]) + } + + StmtKind::Gate => { + let gate = stmt.gate.ok_or(TransformError::MissingField("gate"))?; + let zlup_gate = transform_gate_kind(gate); + + // Invariant check: ensure allocators are defined before use + for target in &stmt.targets { + if let Some(allocator) = &target.array { + ctx.mark_allocator_used(allocator); + } + } + + let targets: Vec = stmt + .targets + .iter() + .map(transform_slot_ref) + .collect::, _>>()?; + + let params: Vec = stmt.params.iter().map(transform_expr).collect(); + + Ok(vec![zlup_ast::Stmt::Gate(zlup_ast::GateOp { + kind: zlup_gate, + targets, + params, + attrs: Vec::new(), + location: None, + })]) + } + + StmtKind::Measure => { + // Determine if we're measuring a single qubit or multiple qubits + // and construct the appropriate result type. + // + // Single qubit (Index expr like q[0]): mz(u1) q[0] + // Multiple qubits (Ident like q, or multiple targets): mz([N]u1) q + + let (targets_expr, result_type) = if stmt.targets.len() == 1 { + let target = &stmt.targets[0]; + if target.kind == ExprKind::Index { + // Single qubit measurement: mz(u1) q[i] + ( + transform_expr(target), + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }), + ) + } else if target.kind == ExprKind::Ident { + // Measuring entire register or single qubit + // Look up the size from the context + let var_name = target.name.as_deref().unwrap_or(""); + let size = ctx.get_qalloc_size(var_name); + + let result_type = if size == Some(1) { + // Single qubit from qubit() call - returns u1 + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }) + } else { + // Multi-qubit register: mz([N]u1) q + zlup_ast::TypeExpr::Array(Box::new(zlup_ast::ArrayType { + element: zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { + bits: 1, + }), + size: size.map(|n| { + zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: n, + suffix: None, + location: None, + }) + }), + sentinel: None, + })) + }; + + // For single qubits, use index [0] instead of the whole register + let target_expr = if size == Some(1) { + zlup_ast::Expr::Index(Box::new(zlup_ast::IndexExpr { + object: transform_expr(target), + index: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + location: None, + })) + } else { + transform_expr(target) + }; + + (target_expr, result_type) + } else { + // Other single target, default to u1 + ( + transform_expr(target), + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }), + ) + } + } else { + // Multiple explicit targets: mz([N]u1) [q[0], q[1], ...] + let n = stmt.targets.len() as i128; + ( + zlup_ast::Expr::BracketArray(Box::new(zlup_ast::BracketArrayExpr { + elements: stmt.targets.iter().map(transform_expr).collect(), + location: None, + })), + zlup_ast::TypeExpr::Array(Box::new(zlup_ast::ArrayType { + element: zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { + bits: 1, + }), + size: Some(zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: n, + suffix: None, + location: None, + })), + sentinel: None, + })), + ) + }; + + // Create MeasureExpr: mz(T) targets + let measure_expr = zlup_ast::Expr::Measure(Box::new(zlup_ast::MeasureExpr { + result_type, + pack: false, + targets: targets_expr, + location: None, + })); + + // If there's a result variable, create a binding + // Otherwise just emit the measure as an expression statement + if let Some(result_name) = stmt.results.first() { + Ok(vec![zlup_ast::Stmt::Binding(zlup_ast::Binding { + name: result_name.clone(), + ty: None, + value: Some(measure_expr), + is_mutable: false, + is_pub: false, + doc_comment: None, + location: None, + })]) + } else { + Ok(vec![zlup_ast::Stmt::Expr(zlup_ast::ExprStmt { + expr: measure_expr, + attrs: Vec::new(), + location: None, + })]) + } + } + + StmtKind::For => { + let var = stmt + .var + .as_ref() + .ok_or(TransformError::MissingField("var"))?; + let range = stmt + .range + .as_ref() + .ok_or(TransformError::MissingField("range"))?; + + // Add loop variable to declared_vars + ctx.declare_var(var); + let body = transform_block_with_ctx(&stmt.body, ctx)?; + + Ok(vec![zlup_ast::Stmt::For(zlup_ast::ForStmt { + label: None, + is_inline: false, + range: zlup_ast::ForRange::Range { + start: transform_expr(&range.start), + end: transform_expr(&range.end), + }, + captures: vec![var.clone()], + body, + location: None, + })]) + } + + StmtKind::While => { + // Zlup doesn't support while loops (NASA Power of 10: bounded iteration only). + // The linter should have already flagged unbounded while loops. + // Transform to a bounded for loop with max iterations and break condition. + let condition = stmt + .condition + .as_ref() + .ok_or(TransformError::MissingField("condition"))?; + + let mut body = transform_block_with_ctx(&stmt.body, ctx)?; + + // Handle condition similar to if statements + let cond_expr = transform_expr(condition); + let bool_condition = match &condition.kind { + ExprKind::Ident => zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: cond_expr, + op: zlup_ast::BinaryOp::Ne, + right: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + location: None, + })), + _ => cond_expr, + }; + + // Insert: if (negated_condition) { break; } at the start of body + // We invert comparison operators directly to avoid precedence issues + // where `!i < limit` parses as `(!i) < limit` instead of `!(i < limit)`. + let negated_condition = negate_condition(&bool_condition); + let break_if_done = zlup_ast::Stmt::If(zlup_ast::IfStmt { + condition: negated_condition, + capture: None, + then_body: zlup_ast::Block { + label: None, + attrs: Vec::new(), + statements: vec![zlup_ast::Stmt::Break(zlup_ast::BreakStmt { + label: None, + value: None, + location: None, + })], + trailing_expr: None, + location: None, + }, + else_body: None, + location: None, + }); + body.statements.insert(0, break_if_done); + + // Use a bounded for loop (max 1000000 iterations as safety bound) + Ok(vec![zlup_ast::Stmt::For(zlup_ast::ForStmt { + label: None, + is_inline: false, + range: zlup_ast::ForRange::Range { + start: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + end: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 1000000, + suffix: None, + location: None, + }), + }, + captures: vec!["_while_iter".to_string()], + body, + location: None, + })]) + } + + StmtKind::Break => Ok(vec![zlup_ast::Stmt::Break(zlup_ast::BreakStmt { + label: None, + value: None, + location: None, + })]), + + StmtKind::Continue => Ok(vec![zlup_ast::Stmt::Continue(zlup_ast::ContinueStmt { + label: None, + location: None, + })]), + + StmtKind::If => { + let condition = stmt + .condition + .as_ref() + .ok_or(TransformError::MissingField("condition"))?; + + let then_body = transform_block_with_ctx(&stmt.then_body, ctx)?; + let else_body = if stmt.else_body.is_empty() { + None + } else { + Some(zlup_ast::ElseBranch::Else(transform_block_with_ctx( + &stmt.else_body, + ctx, + )?)) + }; + + // In Guppy, measurement results (bool) are used directly as conditions. + // In Zlup, measurements return u1, and if conditions require bool. + // We wrap simple identifiers in `!= 0` to convert u1 to bool. + // But we don't wrap comparisons or boolean ops since they already return bool. + let cond_expr = transform_expr(condition); + let bool_condition = match &condition.kind { + // Simple identifiers (like measurement results) need != 0 conversion + ExprKind::Ident => zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: cond_expr, + op: zlup_ast::BinaryOp::Ne, + right: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + location: None, + })), + // Comparisons and binary ops already return bool + _ => cond_expr, + }; + + Ok(vec![zlup_ast::Stmt::If(zlup_ast::IfStmt { + condition: bool_condition, + capture: None, + then_body, + else_body, + location: None, + })]) + } + + StmtKind::Assign => { + let target = stmt + .target + .as_ref() + .ok_or(TransformError::MissingField("target"))?; + let value = stmt + .value + .as_ref() + .ok_or(TransformError::MissingField("value"))?; + + // For simple identifier targets, track if this is a new variable or reassignment + // by checking if ctx has seen this name before. If not seen, create a binding. + // If seen, create an assignment. + if target.kind == ExprKind::Ident { + let name = target + .name + .as_ref() + .ok_or(TransformError::MissingField("name"))?; + if ctx.is_declared(name) { + // Reassignment to existing variable + Ok(vec![zlup_ast::Stmt::Assign(zlup_ast::AssignStmt { + target: transform_expr(target), + op: zlup_ast::AssignOp::Assign, + value: transform_expr(value), + location: None, + })]) + } else { + // New variable declaration + ctx.declare_var(name); + Ok(vec![zlup_ast::Stmt::Binding(zlup_ast::Binding { + name: name.clone(), + ty: None, + value: Some(transform_expr(value)), + is_mutable: false, + is_pub: false, + doc_comment: None, + location: None, + })]) + } + } else { + Ok(vec![zlup_ast::Stmt::Assign(zlup_ast::AssignStmt { + target: transform_expr(target), + op: zlup_ast::AssignOp::Assign, + value: transform_expr(value), + location: None, + })]) + } + } + + StmtKind::Return => { + let value = stmt.return_value.as_ref().map(transform_expr); + + Ok(vec![zlup_ast::Stmt::Return(zlup_ast::ReturnStmt { + value, + location: None, + })]) + } + + StmtKind::Expr => { + let expr = stmt + .expr + .as_ref() + .ok_or(TransformError::MissingField("expr"))?; + + Ok(vec![zlup_ast::Stmt::Expr(zlup_ast::ExprStmt { + expr: transform_expr(expr), + attrs: Vec::new(), + location: None, + })]) + } + + StmtKind::Binding => { + let name = stmt + .name + .as_ref() + .ok_or(TransformError::MissingField("name"))?; + let ty = stmt.ty.as_ref().map(transform_type); + let value = stmt.value.as_ref().map(transform_expr); + + // Register the variable as declared + ctx.declare_var(name); + + Ok(vec![zlup_ast::Stmt::Binding(zlup_ast::Binding { + name: name.clone(), + ty, + value, + is_mutable: stmt.is_mutable.unwrap_or(false), + is_pub: false, + doc_comment: None, + location: None, + })]) + } + + StmtKind::Barrier => Ok(vec![zlup_ast::Stmt::Barrier(zlup_ast::BarrierOp { + allocators: Vec::new(), + location: None, + })]), + + StmtKind::Result => { + let tag = stmt + .tag + .as_ref() + .ok_or(TransformError::MissingField("tag"))?; + let value = stmt + .value + .as_ref() + .ok_or(TransformError::MissingField("value"))?; + + // result(tag, value) becomes an expression statement with ResultExpr + Ok(vec![zlup_ast::Stmt::Expr(zlup_ast::ExprStmt { + expr: zlup_ast::Expr::Result(Box::new(zlup_ast::ResultExpr { + tag: tag.clone(), + value: transform_expr(value), + location: None, + })), + attrs: Vec::new(), + location: None, + })]) + } + } +} + +fn transform_expr(expr: &Expr) -> zlup_ast::Expr { + match expr.kind { + ExprKind::Literal => { + if let Some(value) = &expr.value { + match value { + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: i as i128, + suffix: None, + location: None, + }) + } else if let Some(f) = n.as_f64() { + zlup_ast::Expr::FloatLit(zlup_ast::FloatLit { + value: f, + suffix: None, + location: None, + }) + } else { + zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }) + } + } + serde_json::Value::Bool(b) => zlup_ast::Expr::BoolLit(zlup_ast::BoolLit { + value: *b, + location: None, + }), + serde_json::Value::String(s) => { + zlup_ast::Expr::StringLit(zlup_ast::StringLit { + value: s.clone(), + location: None, + }) + } + serde_json::Value::Null => { + zlup_ast::Expr::Null(zlup_ast::NullLit { location: None }) + } + _ => zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None }), + } + } else { + zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None }) + } + } + + ExprKind::Ident => { + let name = expr.name.as_deref().unwrap_or("_"); + zlup_ast::Expr::Ident(zlup_ast::Ident { + name: name.to_string(), + location: None, + }) + } + + ExprKind::Index => { + let array = expr.array.as_deref().unwrap_or("_"); + let index = expr + .index + .as_ref() + .map(|i| transform_expr(i)) + .unwrap_or_else(|| { + zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }) + }); + + zlup_ast::Expr::Index(Box::new(zlup_ast::IndexExpr { + object: zlup_ast::Expr::Ident(zlup_ast::Ident { + name: array.to_string(), + location: None, + }), + index, + location: None, + })) + } + + ExprKind::Binary => { + let left = expr + .left + .as_ref() + .map(|l| transform_expr(l)) + .unwrap_or_else(|| zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None })); + let right = expr + .right + .as_ref() + .map(|r| transform_expr(r)) + .unwrap_or_else(|| zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None })); + let op_str = expr.op.as_deref().unwrap_or("unknown"); + let op = transform_binary_op(op_str) + .unwrap_or_else(|_| panic!("IR contains unknown binary operator: {}", op_str)); + + zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + op, + left, + right, + location: None, + })) + } + + ExprKind::Unary => { + let operand = expr + .operand + .as_ref() + .map(|o| transform_expr(o)) + .unwrap_or_else(|| zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None })); + let op_str = expr.op.as_deref().unwrap_or("unknown"); + let op = transform_unary_op(op_str) + .unwrap_or_else(|_| panic!("IR contains unknown unary operator: {}", op_str)); + + zlup_ast::Expr::Unary(Box::new(zlup_ast::UnaryExpr { + op, + operand, + location: None, + })) + } + + ExprKind::Call => { + let callee = expr.callee.as_deref().unwrap_or("_"); + let args: Vec = expr.args.iter().map(transform_expr).collect(); + + zlup_ast::Expr::Call(Box::new(zlup_ast::CallExpr { + callee: zlup_ast::Expr::Ident(zlup_ast::Ident { + name: callee.to_string(), + location: None, + }), + args, + location: None, + })) + } + + ExprKind::Field => { + let object = expr + .object + .as_ref() + .map(|o| transform_expr(o)) + .unwrap_or_else(|| zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None })); + let field = expr.field.as_deref().unwrap_or("_"); + + zlup_ast::Expr::Field(Box::new(zlup_ast::FieldExpr { + object, + field: field.to_string(), + location: None, + })) + } + + ExprKind::Tuple => { + let elements: Vec = expr.args.iter().map(transform_expr).collect(); + zlup_ast::Expr::Tuple(Box::new(zlup_ast::TupleExpr { + elements, + location: None, + })) + } + } +} + +fn transform_slot_ref(expr: &Expr) -> Result { + match expr.kind { + ExprKind::Index => { + let allocator = expr.array.as_ref().ok_or(TransformError::InvalidSlotRef)?; + let index = expr + .index + .as_ref() + .map(|i| transform_expr(i)) + .ok_or(TransformError::InvalidSlotRef)?; + + Ok(zlup_ast::SlotRef { + allocator: allocator.clone(), + index: Box::new(index), + location: None, + }) + } + ExprKind::Ident => { + // Single qubit variable (e.g., q0 from q0 = qubit()) + // Treat as allocator[0] since it's a single-qubit allocation + let name = expr.name.as_ref().ok_or(TransformError::InvalidSlotRef)?; + Ok(zlup_ast::SlotRef { + allocator: name.clone(), + index: Box::new(zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + })), + location: None, + }) + } + _ => Err(TransformError::InvalidSlotRef), + } +} + +fn transform_gate_kind(gate: GateKind) -> ZlupGateKind { + match gate { + GateKind::H => ZlupGateKind::H, + GateKind::X => ZlupGateKind::X, + GateKind::Y => ZlupGateKind::Y, + GateKind::Z => ZlupGateKind::Z, + GateKind::T => ZlupGateKind::T, + GateKind::Tdg => ZlupGateKind::Tdg, + GateKind::S => ZlupGateKind::SZ, + GateKind::Sdg => ZlupGateKind::SZdg, + GateKind::Sx => ZlupGateKind::SX, + GateKind::Sy => ZlupGateKind::SY, + GateKind::Sz => ZlupGateKind::SZ, + GateKind::Rx => ZlupGateKind::RX, + GateKind::Ry => ZlupGateKind::RY, + GateKind::Rz => ZlupGateKind::RZ, + GateKind::Cx => ZlupGateKind::CX, + GateKind::Cy => ZlupGateKind::CY, + GateKind::Cz => ZlupGateKind::CZ, + GateKind::Swap => ZlupGateKind::SWAP, + GateKind::Iswap => ZlupGateKind::ISWAP, + GateKind::Ccx => ZlupGateKind::CCX, + GateKind::Pz => ZlupGateKind::PZ, + } +} + +/// Negate a condition expression by inverting comparison operators. +/// This avoids operator precedence issues with the unary `!` operator. +fn negate_condition(expr: &zlup_ast::Expr) -> zlup_ast::Expr { + match expr { + zlup_ast::Expr::Binary(bin) => { + // Invert comparison operators + let inverted_op = match bin.op { + zlup_ast::BinaryOp::Lt => Some(zlup_ast::BinaryOp::Ge), + zlup_ast::BinaryOp::Le => Some(zlup_ast::BinaryOp::Gt), + zlup_ast::BinaryOp::Gt => Some(zlup_ast::BinaryOp::Le), + zlup_ast::BinaryOp::Ge => Some(zlup_ast::BinaryOp::Lt), + zlup_ast::BinaryOp::Eq => Some(zlup_ast::BinaryOp::Ne), + zlup_ast::BinaryOp::Ne => Some(zlup_ast::BinaryOp::Eq), + _ => None, + }; + if let Some(new_op) = inverted_op { + zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: bin.left.clone(), + op: new_op, + right: bin.right.clone(), + location: bin.location.clone(), + })) + } else { + // For non-comparison binary ops (and, or, etc.), use == false + zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: expr.clone(), + op: zlup_ast::BinaryOp::Eq, + right: zlup_ast::Expr::BoolLit(zlup_ast::BoolLit { + value: false, + location: None, + }), + location: None, + })) + } + } + // For other expressions (idents, etc.), use == 0 to negate + _ => zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: expr.clone(), + op: zlup_ast::BinaryOp::Eq, + right: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + location: None, + })), + } +} + +fn transform_binary_op(op: &str) -> Result { + match op { + "add" => Ok(zlup_ast::BinaryOp::Add), + "sub" => Ok(zlup_ast::BinaryOp::Sub), + "mul" => Ok(zlup_ast::BinaryOp::Mul), + "div" => Ok(zlup_ast::BinaryOp::Div), + "floordiv" => Ok(zlup_ast::BinaryOp::Div), // Integer division in Zlup + "mod" => Ok(zlup_ast::BinaryOp::Mod), + "eq" => Ok(zlup_ast::BinaryOp::Eq), + "ne" => Ok(zlup_ast::BinaryOp::Ne), + "lt" => Ok(zlup_ast::BinaryOp::Lt), + "le" => Ok(zlup_ast::BinaryOp::Le), + "gt" => Ok(zlup_ast::BinaryOp::Gt), + "ge" => Ok(zlup_ast::BinaryOp::Ge), + "and" => Ok(zlup_ast::BinaryOp::And), + "or" => Ok(zlup_ast::BinaryOp::Or), + "bitand" => Ok(zlup_ast::BinaryOp::BitAnd), + "bitor" => Ok(zlup_ast::BinaryOp::BitOr), + "bitxor" => Ok(zlup_ast::BinaryOp::BitXor), + "shl" => Ok(zlup_ast::BinaryOp::Shl), + "shr" => Ok(zlup_ast::BinaryOp::Shr), + _ => Err(TransformError::UnsupportedOp(format!( + "unknown binary operator: {}", + op + ))), + } +} + +fn transform_unary_op(op: &str) -> Result { + match op { + "neg" => Ok(zlup_ast::UnaryOp::Neg), + "not" => Ok(zlup_ast::UnaryOp::Not), + "bitnot" => Ok(zlup_ast::UnaryOp::BitNot), + _ => Err(TransformError::UnsupportedOp(format!( + "unknown unary operator: {}", + op + ))), + } +} + +/// Transform error. +#[derive(Debug, thiserror::Error)] +pub enum TransformError { + #[error("Missing required field: {0}")] + MissingField(&'static str), + + #[error("Invalid slot reference")] + InvalidSlotRef, + + #[error("Unsupported feature: {0}")] + Unsupported(String), + + #[error("Unsupported operator: {0}")] + UnsupportedOp(String), + + #[error("Generated code validation failed: {0}")] + ValidationFailed(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transform_empty_program() { + let ir = GuppyIR { + version: "0.1.0".to_string(), + functions: vec![], + source_file: None, + }; + + let result = transform(&ir).unwrap(); + assert!(result.declarations.is_empty()); + } + + #[test] + fn test_transform_simple_function() { + let ir = GuppyIR { + version: "0.1.0".to_string(), + functions: vec![Function { + name: "main".to_string(), + params: vec![], + return_type: None, + body: vec![], + is_pub: None, + location: None, + }], + source_file: None, + }; + + let result = transform(&ir).unwrap(); + assert_eq!(result.declarations.len(), 1); + } +} diff --git a/exp/guppy-zlup/src/ir.rs b/exp/guppy-zlup/src/ir.rs new file mode 100644 index 000000000..0c9f62c3a --- /dev/null +++ b/exp/guppy-zlup/src/ir.rs @@ -0,0 +1,2590 @@ +//! Guppy IR types and emitter. +//! +//! This module defines the intermediate representation for validated Guppy programs +//! and provides functions to emit IR from Python source code. + +use rustpython_parser::ast::{self, Constant, Expr as PyExpr, Mod, Stmt as PyStmt}; +use serde::{Deserialize, Serialize}; + +pub const IR_VERSION: &str = "0.1.0"; + +/// Expression kinds in the IR. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ExprKind { + Literal, + Ident, + Index, + Binary, + Unary, + Call, + Field, + Tuple, +} + +/// Statement kinds in the IR. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StmtKind { + Qalloc, + Gate, + Measure, + For, + While, + If, + Assign, + Return, + Expr, + Binding, + Barrier, + Break, + Continue, + /// Result emission: result(tag, value) + Result, +} + +/// Gate types. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum GateKind { + H, + X, + Y, + Z, + T, + Tdg, + S, + Sdg, + Sx, + Sy, + Sz, + Rx, + Ry, + Rz, + Cx, + Cy, + Cz, + Swap, + Iswap, + Ccx, + Pz, +} + +impl GateKind { + pub fn from_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "h" => Some(GateKind::H), + "x" => Some(GateKind::X), + "y" => Some(GateKind::Y), + "z" => Some(GateKind::Z), + "t" => Some(GateKind::T), + "tdg" => Some(GateKind::Tdg), + "s" => Some(GateKind::S), + "sdg" => Some(GateKind::Sdg), + "sx" => Some(GateKind::Sx), + "sy" => Some(GateKind::Sy), + "sz" => Some(GateKind::Sz), + "rx" => Some(GateKind::Rx), + "ry" => Some(GateKind::Ry), + "rz" => Some(GateKind::Rz), + "cx" | "cnot" => Some(GateKind::Cx), + "cy" => Some(GateKind::Cy), + "cz" => Some(GateKind::Cz), + "swap" => Some(GateKind::Swap), + "iswap" => Some(GateKind::Iswap), + "ccx" | "toffoli" => Some(GateKind::Ccx), + "pz" | "reset" => Some(GateKind::Pz), + _ => None, + } + } + + pub fn to_zlup_name(&self) -> &'static str { + match self { + GateKind::H => "h", + GateKind::X => "x", + GateKind::Y => "y", + GateKind::Z => "z", + GateKind::T => "t", + GateKind::Tdg => "tdg", + GateKind::S => "sz", + GateKind::Sdg => "szdg", + GateKind::Sx => "sx", + GateKind::Sy => "sy", + GateKind::Sz => "sz", + GateKind::Rx => "rx", + GateKind::Ry => "ry", + GateKind::Rz => "rz", + GateKind::Cx => "cx", + GateKind::Cy => "cy", + GateKind::Cz => "cz", + GateKind::Swap => "swap", + GateKind::Iswap => "iswap", + GateKind::Ccx => "ccx", + GateKind::Pz => "pz", + } + } + + pub fn arity(&self) -> usize { + match self { + GateKind::Ccx => 3, + GateKind::Cx | GateKind::Cy | GateKind::Cz | GateKind::Swap | GateKind::Iswap => 2, + _ => 1, + } + } + + pub fn is_parameterized(&self) -> bool { + matches!(self, GateKind::Rx | GateKind::Ry | GateKind::Rz) + } +} + +/// Source location. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_column: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, +} + +/// Type expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypeExpr { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub element: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option>, + /// For tuple types: the element types + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub elements: Vec, +} + +/// Expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Expr { + pub kind: ExprKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub array: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub op: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub left: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub right: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub operand: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub callee: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +/// Range expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RangeExpr { + pub start: Expr, + pub end: Expr, +} + +/// Statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Stmt { + pub kind: StmtKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gate: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub targets: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub params: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub var: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub condition: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub then_body: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub else_body: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub return_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expr: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub ty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_mutable: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub body: Vec, + /// Tag for result statements: result(tag, value) + #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +/// Function parameter. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Param { + pub name: String, + #[serde(rename = "type")] + pub ty: TypeExpr, +} + +/// Function definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Function { + pub name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub params: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub return_type: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub body: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_pub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +/// Root IR node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuppyIR { + pub version: String, + pub functions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_file: Option, +} + +impl GuppyIR { + pub fn new() -> Self { + Self { + version: IR_VERSION.to_string(), + functions: Vec::new(), + source_file: None, + } + } +} + +impl Default for GuppyIR { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// IR Validation +// ============================================================================= + +/// IR validation error. +#[derive(Debug, Clone)] +pub enum ValidationError { + /// Missing required field for statement kind. + MissingField { + kind: StmtKind, + field: &'static str, + location: Option, + }, + /// Missing required field for expression kind. + MissingExprField { kind: ExprKind, field: &'static str }, + /// Undefined variable reference. + UndefinedVariable { + name: String, + location: Option, + }, + /// Undefined allocator (qubit register) reference. + UndefinedAllocator { + name: String, + location: Option, + }, + /// Gate used before qalloc. + GateBeforeAlloc { + allocator: String, + location: Option, + }, + /// Invalid gate arity. + InvalidGateArity { + gate: GateKind, + expected: usize, + actual: usize, + location: Option, + }, + /// Unknown operator. + UnknownOperator { + op: String, + location: Option, + }, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValidationError::MissingField { kind, field, .. } => write!( + f, + "missing required field '{}' for {:?} statement", + field, kind + ), + ValidationError::MissingExprField { kind, field } => write!( + f, + "missing required field '{}' for {:?} expression", + field, kind + ), + ValidationError::UndefinedVariable { name, .. } => { + write!(f, "undefined variable: {}", name) + } + ValidationError::UndefinedAllocator { name, .. } => { + write!(f, "undefined qubit allocator: {}", name) + } + ValidationError::GateBeforeAlloc { allocator, .. } => write!( + f, + "gate uses allocator '{}' before it was allocated", + allocator + ), + ValidationError::InvalidGateArity { + gate, + expected, + actual, + .. + } => write!( + f, + "gate {:?} expects {} targets, got {}", + gate, expected, actual + ), + ValidationError::UnknownOperator { op, .. } => write!(f, "unknown operator: {}", op), + } + } +} + +impl std::error::Error for ValidationError {} + +/// IR validation result. +#[derive(Debug, Clone, Default)] +pub struct ValidationResult { + pub errors: Vec, + pub warnings: Vec, +} + +impl ValidationResult { + pub fn is_valid(&self) -> bool { + self.errors.is_empty() + } + + pub fn add_error(&mut self, error: ValidationError) { + self.errors.push(error); + } + + pub fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } +} + +/// IR validator with semantic tracking. +#[derive(Debug, Default)] +pub struct IrValidator { + /// Known variables in scope. + variables: std::collections::BTreeSet, + /// Known qubit allocators. + allocators: std::collections::BTreeSet, + /// Validation results. + result: ValidationResult, +} + +impl IrValidator { + pub fn new() -> Self { + Self::default() + } + + /// Validate the entire IR. + pub fn validate(&mut self, ir: &GuppyIR) -> ValidationResult { + for func in &ir.functions { + self.validate_function(func); + } + std::mem::take(&mut self.result) + } + + /// Validate a function. + fn validate_function(&mut self, func: &Function) { + // Clear per-function state + self.variables.clear(); + self.allocators.clear(); + + // Add parameters to variables + for param in &func.params { + self.variables.insert(param.name.clone()); + } + + // Validate body + for stmt in &func.body { + self.validate_stmt(stmt); + } + } + + /// Validate a statement. + fn validate_stmt(&mut self, stmt: &Stmt) { + match stmt.kind { + StmtKind::Qalloc => { + // Schema: requires name + if let Some(name) = &stmt.name { + self.allocators.insert(name.clone()); + self.variables.insert(name.clone()); + } else { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "name", + location: stmt.location.clone(), + }); + } + } + + StmtKind::Gate => { + // Schema: requires gate + if let Some(gate) = &stmt.gate { + // Check arity + if stmt.targets.len() != gate.arity() { + self.result.add_error(ValidationError::InvalidGateArity { + gate: *gate, + expected: gate.arity(), + actual: stmt.targets.len(), + location: stmt.location.clone(), + }); + } + } else { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "gate", + location: stmt.location.clone(), + }); + } + // Validate targets reference known allocators + for target in &stmt.targets { + self.validate_qubit_ref(target, &stmt.location); + } + // Validate params are valid expressions + for param in &stmt.params { + self.validate_expr(param); + } + } + + StmtKind::Measure => { + // Validate targets + for target in &stmt.targets { + self.validate_qubit_ref(target, &stmt.location); + } + // Add results to variables + for result in &stmt.results { + self.variables.insert(result.clone()); + } + } + + StmtKind::For => { + // Schema: requires var and range + if stmt.var.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "var", + location: stmt.location.clone(), + }); + } + if stmt.range.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "range", + location: stmt.location.clone(), + }); + } + // Add loop var to scope and validate body + if let Some(var) = &stmt.var { + self.variables.insert(var.clone()); + } + for s in &stmt.body { + self.validate_stmt(s); + } + } + + StmtKind::While => { + // Schema: requires condition + if let Some(condition) = &stmt.condition { + self.validate_expr(condition); + } else { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "condition", + location: stmt.location.clone(), + }); + } + for s in &stmt.body { + self.validate_stmt(s); + } + } + + StmtKind::If => { + // Schema: requires condition + if let Some(condition) = &stmt.condition { + self.validate_expr(condition); + } else { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "condition", + location: stmt.location.clone(), + }); + } + for s in &stmt.then_body { + self.validate_stmt(s); + } + for s in &stmt.else_body { + self.validate_stmt(s); + } + } + + StmtKind::Assign => { + // Validate target and value + if let Some(target) = &stmt.target { + // For simple idents, don't require prior definition (could be new var) + self.validate_expr_allow_new_var(target); + } + if let Some(value) = &stmt.value { + self.validate_expr(value); + } + // If target is a simple ident, add to variables + if let Some(target) = &stmt.target + && target.kind == ExprKind::Ident + && let Some(name) = &target.name + { + self.variables.insert(name.clone()); + } + } + + StmtKind::Binding => { + // Schema: requires name + if stmt.name.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "name", + location: stmt.location.clone(), + }); + } else { + self.variables.insert(stmt.name.clone().unwrap()); + } + if let Some(value) = &stmt.value { + self.validate_expr(value); + } + } + + StmtKind::Return => { + if let Some(value) = &stmt.return_value { + self.validate_expr(value); + } + } + + StmtKind::Expr => { + if let Some(expr) = &stmt.expr { + self.validate_expr(expr); + } + } + + StmtKind::Result => { + if let Some(value) = &stmt.value { + self.validate_expr(value); + } + } + + StmtKind::Break | StmtKind::Continue | StmtKind::Barrier => { + // No additional validation needed + } + } + } + + /// Validate a qubit reference (index into allocator). + fn validate_qubit_ref(&mut self, expr: &Expr, stmt_location: &Option) { + match &expr.kind { + ExprKind::Index => { + if let Some(array) = &expr.array + && !self.allocators.contains(array) + { + self.result.add_error(ValidationError::UndefinedAllocator { + name: array.clone(), + location: stmt_location.clone(), + }); + } + } + ExprKind::Ident => { + // Single qubit reference + if let Some(name) = &expr.name + && !self.variables.contains(name) + { + self.result.add_error(ValidationError::UndefinedVariable { + name: name.clone(), + location: stmt_location.clone(), + }); + } + } + _ => {} + } + } + + /// Validate an expression (recursively). + fn validate_expr(&mut self, expr: &Expr) { + match &expr.kind { + ExprKind::Ident => { + if let Some(name) = &expr.name + && !self.variables.contains(name) + { + self.result.add_error(ValidationError::UndefinedVariable { + name: name.clone(), + location: expr.location.clone(), + }); + } + } + ExprKind::Index => { + if let Some(array) = &expr.array + && !self.variables.contains(array) + && !self.allocators.contains(array) + { + self.result.add_error(ValidationError::UndefinedVariable { + name: array.clone(), + location: expr.location.clone(), + }); + } + if let Some(index) = &expr.index { + self.validate_expr(index); + } + } + ExprKind::Binary => { + // Validate operator + if let Some(op) = &expr.op { + let valid_ops = [ + "add", "sub", "mul", "div", "floordiv", "mod", "eq", "ne", "lt", "le", + "gt", "ge", "and", "or", "bitand", "bitor", "bitxor", "shl", "shr", + ]; + if !valid_ops.contains(&op.as_str()) { + self.result.add_error(ValidationError::UnknownOperator { + op: op.clone(), + location: expr.location.clone(), + }); + } + } + if let Some(left) = &expr.left { + self.validate_expr(left); + } + if let Some(right) = &expr.right { + self.validate_expr(right); + } + } + ExprKind::Unary => { + if let Some(op) = &expr.op { + let valid_ops = ["neg", "not", "bitnot"]; + if !valid_ops.contains(&op.as_str()) { + self.result.add_error(ValidationError::UnknownOperator { + op: op.clone(), + location: expr.location.clone(), + }); + } + } + if let Some(operand) = &expr.operand { + self.validate_expr(operand); + } + } + ExprKind::Call => { + for arg in &expr.args { + self.validate_expr(arg); + } + } + ExprKind::Field => { + if let Some(object) = &expr.object { + self.validate_expr(object); + } + } + ExprKind::Tuple => { + for arg in &expr.args { + self.validate_expr(arg); + } + } + ExprKind::Literal => { + // No validation needed for literals + } + } + } + + /// Validate expression but allow undefined identifiers (for assignment targets). + fn validate_expr_allow_new_var(&mut self, expr: &Expr) { + match &expr.kind { + ExprKind::Ident => { + // Allow undefined - this might be a new variable + } + ExprKind::Index => { + // Array must exist, but we check elsewhere + if let Some(index) = &expr.index { + self.validate_expr(index); + } + } + _ => self.validate_expr(expr), + } + } +} + +/// Validate IR and return result. +pub fn validate_ir(ir: &GuppyIR) -> ValidationResult { + let mut validator = IrValidator::new(); + validator.validate(ir) +} + +// ============================================================================= +// IR Emission +// ============================================================================= + +/// Error during IR emission. +#[derive(Debug, Clone)] +pub enum EmitError { + /// Parse error from rustpython. + Parse(String), + /// The input was not a module. + NotAModule, + /// Unsupported Python construct. + Unsupported(String), + /// Skip this node (internal). + Skip, +} + +impl std::fmt::Display for EmitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EmitError::Parse(msg) => write!(f, "parse error: {}", msg), + EmitError::NotAModule => write!(f, "expected a module"), + EmitError::Unsupported(msg) => write!(f, "unsupported: {}", msg), + EmitError::Skip => write!(f, "skip"), + } + } +} + +impl std::error::Error for EmitError {} + +/// Emit IR from Python source. +pub fn emit_ir(source: &str, filename: Option<&str>) -> Result { + let parsed = rustpython_parser::parse( + source, + rustpython_parser::Mode::Module, + filename.unwrap_or(""), + ) + .map_err(|e| EmitError::Parse(e.to_string()))?; + + let mut ir = GuppyIR::new(); + ir.source_file = filename.map(String::from); + + let Mod::Module(module) = parsed else { + return Err(EmitError::NotAModule); + }; + + for stmt in module.body { + if let PyStmt::FunctionDef(func) = stmt { + ir.functions.push(convert_function(&func, source)?); + } + } + + Ok(ir) +} + +fn convert_function(func: &ast::StmtFunctionDef, source: &str) -> Result { + let params = func + .args + .args + .iter() + .map(|arg| { + let ty = arg + .def + .annotation + .as_ref() + .map(|ann| convert_type_annotation(ann)) + .unwrap_or_else(|| TypeExpr { + kind: "primitive".to_string(), + name: Some("unknown".to_string()), + element: None, + size: None, + elements: Vec::new(), + }); + Param { + name: arg.def.arg.to_string(), + ty, + } + }) + .collect(); + + let return_type = func + .returns + .as_ref() + .map(|ann| convert_type_annotation(ann)); + + let body = func + .body + .iter() + .filter_map(|stmt| convert_stmt(stmt).ok()) + .collect(); + + let (line, column) = offset_to_line_col(source, func.range.start().into()); + let (end_line, end_column) = offset_to_line_col(source, func.range.end().into()); + + Ok(Function { + name: func.name.to_string(), + params, + return_type, + body, + is_pub: None, + location: Some(SourceLocation { + line: line as u32, + column: column as u32, + end_line: Some(end_line as u32), + end_column: Some(end_column as u32), + file: None, + }), + }) +} + +fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { + let mut line = 1; + let mut col = 1; + for (i, ch) in source.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (line, col) +} + +fn convert_type_annotation(ann: &PyExpr) -> TypeExpr { + match ann { + PyExpr::Name(name) => { + let name_str = name.id.as_str(); + match name_str { + "int" | "float" | "bool" | "str" | "None" => TypeExpr { + kind: "primitive".to_string(), + name: Some(name_str.to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + "qubit" => TypeExpr { + kind: "qalloc".to_string(), + name: None, + element: None, + size: None, + elements: Vec::new(), + }, + _ => TypeExpr { + kind: "named".to_string(), + name: Some(name_str.to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + } + } + PyExpr::Subscript(sub) => { + if let PyExpr::Name(name) = sub.value.as_ref() { + let name_str = name.id.as_str(); + match name_str { + "list" | "List" => TypeExpr { + kind: "array".to_string(), + name: None, + element: Some(Box::new(convert_type_annotation(&sub.slice))), + size: None, + elements: Vec::new(), + }, + "Optional" => TypeExpr { + kind: "optional".to_string(), + name: None, + element: Some(Box::new(convert_type_annotation(&sub.slice))), + size: None, + elements: Vec::new(), + }, + "qubit" => TypeExpr { + kind: "qalloc".to_string(), + name: None, + element: None, + size: convert_expr(&sub.slice).ok().map(Box::new), + elements: Vec::new(), + }, + "tuple" => { + // tuple[T1, T2, ...] - the slice is a Tuple of types + let elements = if let PyExpr::Tuple(tuple) = sub.slice.as_ref() { + tuple.elts.iter().map(convert_type_annotation).collect() + } else { + // Single element tuple + vec![convert_type_annotation(&sub.slice)] + }; + TypeExpr { + kind: "tuple".to_string(), + name: None, + element: None, + size: None, + elements, + } + } + _ => TypeExpr { + kind: "named".to_string(), + name: Some(name_str.to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + } + } else { + TypeExpr { + kind: "primitive".to_string(), + name: Some("unknown".to_string()), + element: None, + size: None, + elements: Vec::new(), + } + } + } + PyExpr::Constant(c) if matches!(c.value, Constant::None) => TypeExpr { + kind: "primitive".to_string(), + name: Some("None".to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + _ => TypeExpr { + kind: "primitive".to_string(), + name: Some("unknown".to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + } +} + +fn convert_stmt(stmt: &PyStmt) -> Result { + match stmt { + PyStmt::Assign(assign) => { + if assign.targets.len() != 1 { + return Err(EmitError::Unsupported("multiple assignment targets".into())); + } + + // Check for qalloc: q = qubit[4] + if let PyExpr::Subscript(sub) = assign.value.as_ref() + && let PyExpr::Name(name) = sub.value.as_ref() + && (name.id.as_str() == "qubit" || name.id.as_str() == "qalloc") + && let PyExpr::Name(target) = &assign.targets[0] + { + return Ok(Stmt { + kind: StmtKind::Qalloc, + name: Some(target.id.to_string()), + size: convert_expr(&sub.slice).ok(), + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Check for single qubit alloc: q = qubit() + if let PyExpr::Call(call) = assign.value.as_ref() + && let Some(callee_name) = get_callee_name(&call.func) + && (callee_name == "qubit" || callee_name == "qalloc") + && let PyExpr::Name(target) = &assign.targets[0] + { + // Single qubit allocation - size is 1 + return Ok(Stmt { + kind: StmtKind::Qalloc, + name: Some(target.id.to_string()), + size: Some(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(1)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }), + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Check for measure: m = measure(q) + if let PyExpr::Call(call) = assign.value.as_ref() + && let Some(callee_name) = get_callee_name(&call.func) + && (callee_name == "measure" || callee_name == "mz" || callee_name == "measure_all") + && let PyExpr::Name(result_var) = &assign.targets[0] + { + let targets = call + .args + .iter() + .filter_map(|arg| convert_expr(arg).ok()) + .collect(); + + return Ok(Stmt { + kind: StmtKind::Measure, + name: None, + size: None, + gate: None, + targets, + params: Vec::new(), + results: vec![result_var.id.to_string()], + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + let target = convert_expr(&assign.targets[0])?; + let value = convert_expr(&assign.value)?; + + Ok(Stmt { + kind: StmtKind::Assign, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: Some(target), + value: Some(value), + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + PyStmt::Expr(expr_stmt) => { + // Check for gate calls + if let PyExpr::Call(call) = expr_stmt.value.as_ref() { + let callee_name = get_callee_name(&call.func); + if let Some(name) = &callee_name { + if let Some(gate) = GateKind::from_name(name) { + let mut targets: Vec = call + .args + .iter() + .filter_map(|arg| convert_expr(arg).ok()) + .collect(); + + // For parameterized gates like rx(q, angle), the last argument is the angle + let mut params = Vec::new(); + if gate.is_parameterized() && !targets.is_empty() { + params.push(targets.pop().unwrap()); + } + + return Ok(Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: Some(gate), + targets, + params, + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Check for measure + if name == "measure" || name == "mz" || name == "measure_all" { + let targets = call + .args + .iter() + .filter_map(|arg| convert_expr(arg).ok()) + .collect(); + + return Ok(Stmt { + kind: StmtKind::Measure, + name: None, + size: None, + gate: None, + targets, + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Check for result: result(tag, value) + if name == "result" && call.args.len() >= 2 { + let tag = if let PyExpr::Constant(c) = &call.args[0] { + if let Constant::Str(s) = &c.value { + Some(s.to_string()) + } else { + None + } + } else { + None + }; + + let value = convert_expr(&call.args[1]).ok(); + + return Ok(Stmt { + kind: StmtKind::Result, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag, + location: None, + }); + } + } + } + + // Skip docstrings (string constant expressions) + if let PyExpr::Constant(c) = expr_stmt.value.as_ref() + && matches!(c.value, Constant::Str(_)) + { + return Err(EmitError::Skip); + } + + let expr = convert_expr(&expr_stmt.value)?; + Ok(Stmt { + kind: StmtKind::Expr, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: Some(expr), + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + PyStmt::For(for_stmt) => { + let var = if let PyExpr::Name(name) = for_stmt.target.as_ref() { + name.id.to_string() + } else { + return Err(EmitError::Unsupported("complex for target".into())); + }; + + let range = if let PyExpr::Call(call) = for_stmt.iter.as_ref() { + if let PyExpr::Name(name) = call.func.as_ref() { + if name.id.as_str() == "range" { + let args: Vec<_> = call + .args + .iter() + .filter_map(|a| convert_expr(a).ok()) + .collect(); + + if args.len() == 1 { + Some(RangeExpr { + start: Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(0)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }, + end: args.into_iter().next().unwrap(), + }) + } else if args.len() >= 2 { + let mut iter = args.into_iter(); + Some(RangeExpr { + start: iter.next().unwrap(), + end: iter.next().unwrap(), + }) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + let body = for_stmt + .body + .iter() + .filter_map(|s| convert_stmt(s).ok()) + .collect(); + + Ok(Stmt { + kind: StmtKind::For, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: Some(var), + range, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body, + tag: None, + location: None, + }) + } + + PyStmt::While(while_stmt) => { + let condition = convert_expr(&while_stmt.test)?; + let body = while_stmt + .body + .iter() + .filter_map(|s| convert_stmt(s).ok()) + .collect(); + + Ok(Stmt { + kind: StmtKind::While, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: Some(condition), + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body, + tag: None, + location: None, + }) + } + + PyStmt::Break(_) => Ok(Stmt { + kind: StmtKind::Break, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }), + + PyStmt::Continue(_) => Ok(Stmt { + kind: StmtKind::Continue, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }), + + PyStmt::If(if_stmt) => { + let condition = convert_expr(&if_stmt.test)?; + let then_body = if_stmt + .body + .iter() + .filter_map(|s| convert_stmt(s).ok()) + .collect(); + let else_body = if_stmt + .orelse + .iter() + .filter_map(|s| convert_stmt(s).ok()) + .collect(); + + Ok(Stmt { + kind: StmtKind::If, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: Some(condition), + then_body, + else_body, + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + PyStmt::Return(ret) => { + let return_value = ret.value.as_ref().and_then(|v| convert_expr(v).ok()); + + Ok(Stmt { + kind: StmtKind::Return, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + PyStmt::Pass(_) => Err(EmitError::Skip), + + PyStmt::AnnAssign(ann) => { + // Handle annotated assignment: x: int = 5 + let name = if let PyExpr::Name(name) = ann.target.as_ref() { + name.id.to_string() + } else { + return Err(EmitError::Unsupported("complex annotated target".into())); + }; + + // Check for qalloc: q: qubit[4] or q: qubit[4] = ... + if let PyExpr::Subscript(sub) = ann.annotation.as_ref() + && let PyExpr::Name(type_name) = sub.value.as_ref() + && (type_name.id.as_str() == "qubit" || type_name.id.as_str() == "qalloc") + { + return Ok(Stmt { + kind: StmtKind::Qalloc, + name: Some(name), + size: convert_expr(&sub.slice).ok(), + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Regular annotated assignment -> Binding + let ty = convert_type_annotation(&ann.annotation); + let value = ann.value.as_ref().and_then(|v| convert_expr(v).ok()); + + Ok(Stmt { + kind: StmtKind::Binding, + name: Some(name), + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value, + return_value: None, + expr: None, + ty: Some(ty), + is_mutable: Some(true), // Assume mutable by default + body: Vec::new(), + tag: None, + location: None, + }) + } + + // Augmented assignment: i += 1, x -= 2, etc. + PyStmt::AugAssign(aug) => { + let target = convert_expr(&aug.target)?; + let value = convert_expr(&aug.value)?; + + // Convert augmented assignment to regular assignment with binary op + // i += 1 becomes i = i + 1 + let op = match aug.op { + ast::Operator::Add => "add", + ast::Operator::Sub => "sub", + ast::Operator::Mult => "mul", + ast::Operator::Div => "div", + ast::Operator::Mod => "mod", + ast::Operator::BitAnd => "bitand", + ast::Operator::BitOr => "bitor", + ast::Operator::BitXor => "bitxor", + ast::Operator::LShift => "shl", + ast::Operator::RShift => "shr", + _ => { + return Err(EmitError::Unsupported(format!( + "augmented operator: {:?}", + aug.op + ))); + } + }; + + Ok(Stmt { + kind: StmtKind::Assign, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: Some(target.clone()), + value: Some(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(target)), + right: Some(Box::new(value)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }), + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + _ => Err(EmitError::Unsupported(format!( + "statement type: {:?}", + std::mem::discriminant(stmt) + ))), + } +} + +fn convert_expr(expr: &PyExpr) -> Result { + match expr { + PyExpr::Constant(c) => { + let value = match &c.value { + Constant::Int(i) => serde_json::json!(i.to_string().parse::().unwrap_or(0)), + Constant::Float(f) => serde_json::json!(f), + Constant::Str(s) => serde_json::json!(s), + Constant::Bool(b) => serde_json::json!(b), + Constant::None => serde_json::Value::Null, + _ => serde_json::json!(null), + }; + Ok(Expr { + kind: ExprKind::Literal, + value: Some(value), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } + + PyExpr::Name(name) => Ok(Expr { + kind: ExprKind::Ident, + value: None, + name: Some(name.id.to_string()), + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }), + + PyExpr::Subscript(sub) => { + if let PyExpr::Name(name) = sub.value.as_ref() { + let index = convert_expr(&sub.slice)?; + Ok(Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some(name.id.to_string()), + index: Some(Box::new(index)), + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } else { + Err(EmitError::Unsupported("complex subscript".into())) + } + } + + PyExpr::BinOp(binop) => { + let left = convert_expr(&binop.left)?; + let right = convert_expr(&binop.right)?; + let op = match binop.op { + ast::Operator::Add => "add", + ast::Operator::Sub => "sub", + ast::Operator::Mult => "mul", + ast::Operator::Div => "div", + ast::Operator::FloorDiv => "floordiv", + ast::Operator::Mod => "mod", + ast::Operator::BitAnd => "bitand", + ast::Operator::BitOr => "bitor", + ast::Operator::BitXor => "bitxor", + ast::Operator::LShift => "shl", + ast::Operator::RShift => "shr", + other => { + return Err(EmitError::Unsupported(format!( + "binary operator: {:?}", + other + ))); + } + }; + + Ok(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(left)), + right: Some(Box::new(right)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } + + PyExpr::UnaryOp(unary) => { + let operand = convert_expr(&unary.operand)?; + let op = match unary.op { + ast::UnaryOp::UAdd => "pos", + ast::UnaryOp::USub => "neg", + ast::UnaryOp::Not => "not", + ast::UnaryOp::Invert => "bitnot", + }; + + Ok(Expr { + kind: ExprKind::Unary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: None, + right: None, + operand: Some(Box::new(operand)), + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } + + PyExpr::Compare(cmp) => { + let left = convert_expr(&cmp.left)?; + if let Some((cmpop, right_expr)) = cmp.ops.first().zip(cmp.comparators.first()) { + let right = convert_expr(right_expr)?; + let op = match cmpop { + ast::CmpOp::Eq => "eq", + ast::CmpOp::NotEq => "ne", + ast::CmpOp::Lt => "lt", + ast::CmpOp::LtE => "le", + ast::CmpOp::Gt => "gt", + ast::CmpOp::GtE => "ge", + other => { + return Err(EmitError::Unsupported(format!( + "comparison operator: {:?}", + other + ))); + } + }; + + Ok(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(left)), + right: Some(Box::new(right)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } else { + Ok(left) + } + } + + PyExpr::BoolOp(boolop) => { + // Handle `and` and `or` operators + // BoolOp can have multiple values: a and b and c + // We convert to nested binary expressions: (a and b) and c + let op = match boolop.op { + ast::BoolOp::And => "and", + ast::BoolOp::Or => "or", + }; + + if boolop.values.len() < 2 { + return Err(EmitError::Unsupported("boolop with < 2 values".into())); + } + + let mut iter = boolop.values.iter(); + let first = convert_expr(iter.next().unwrap())?; + let second = convert_expr(iter.next().unwrap())?; + + let mut result = Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(first)), + right: Some(Box::new(second)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }; + + // Handle chained operators: a and b and c -> (a and b) and c + for val in iter { + let right = convert_expr(val)?; + result = Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(result)), + right: Some(Box::new(right)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }; + } + + Ok(result) + } + + PyExpr::Call(call) => { + let callee = get_callee_name(&call.func); + let args = call + .args + .iter() + .filter_map(|a| convert_expr(a).ok()) + .collect(); + + Ok(Expr { + kind: ExprKind::Call, + value: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee, + args, + object: None, + field: None, + location: None, + }) + } + + PyExpr::Attribute(attr) => { + let object = convert_expr(&attr.value)?; + Ok(Expr { + kind: ExprKind::Field, + value: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: Some(Box::new(object)), + field: Some(attr.attr.to_string()), + location: None, + }) + } + + PyExpr::Tuple(tuple) => { + let elements: Vec = tuple + .elts + .iter() + .filter_map(|e| convert_expr(e).ok()) + .collect(); + + Ok(Expr { + kind: ExprKind::Tuple, + value: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: elements, + object: None, + field: None, + location: None, + }) + } + + _ => Err(EmitError::Unsupported(format!( + "expression type: {:?}", + std::mem::discriminant(expr) + ))), + } +} + +fn get_callee_name(expr: &PyExpr) -> Option { + match expr { + PyExpr::Name(name) => Some(name.id.to_string()), + PyExpr::Attribute(attr) => Some(attr.attr.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod validation_tests { + use super::*; + + fn make_ir(body: Vec) -> GuppyIR { + GuppyIR { + version: IR_VERSION.to_string(), + functions: vec![Function { + name: "test".to_string(), + params: vec![], + return_type: None, + body, + is_pub: None, + location: None, + }], + source_file: None, + } + } + + #[test] + fn test_valid_qalloc_gate() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Qalloc, + name: Some("q".to_string()), + size: Some(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(4)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }), + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }, + Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: Some(GateKind::H), + targets: vec![Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some("q".to_string()), + index: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(0)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!( + result.is_valid(), + "Expected valid IR, got errors: {:?}", + result.errors + ); + } + + #[test] + fn test_undefined_allocator() { + let ir = make_ir(vec![ + // Use q[0] without allocating q first + Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: Some(GateKind::H), + targets: vec![Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some("q".to_string()), + index: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(0)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!( + !result.is_valid(), + "Expected validation error for undefined allocator" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::UndefinedAllocator { .. })) + ); + } + + #[test] + fn test_missing_gate_field() { + let ir = make_ir(vec![Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: None, // Missing required gate field + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }]); + + let result = validate_ir(&ir); + assert!( + !result.is_valid(), + "Expected validation error for missing gate field" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::MissingField { field: "gate", .. })) + ); + } + + #[test] + fn test_invalid_gate_arity() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Qalloc, + name: Some("q".to_string()), + size: Some(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(2)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }), + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }, + Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: Some(GateKind::H), // H is 1-qubit gate + targets: vec![ + // But we provide 2 targets + Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some("q".to_string()), + index: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(0)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }, + Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some("q".to_string()), + index: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(1)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }, + ], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!( + !result.is_valid(), + "Expected validation error for invalid gate arity" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::InvalidGateArity { .. })) + ); + } + + #[test] + fn test_unknown_operator() { + let ir = make_ir(vec![Stmt { + kind: StmtKind::Return, + name: None, + size: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: Some(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some("invalid_op".to_string()), // Unknown operator + left: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(1)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + right: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(2)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }), + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }]); + + let result = validate_ir(&ir); + assert!( + !result.is_valid(), + "Expected validation error for unknown operator" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::UnknownOperator { .. })) + ); + } + + #[test] + fn test_undefined_variable() { + let ir = make_ir(vec![Stmt { + kind: StmtKind::Return, + name: None, + size: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: Some(Expr { + kind: ExprKind::Ident, + value: None, + name: Some("undefined_var".to_string()), + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }), + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }]); + + let result = validate_ir(&ir); + assert!( + !result.is_valid(), + "Expected validation error for undefined variable" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::UndefinedVariable { .. })) + ); + } + + #[test] + fn test_variable_from_binding() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Binding, + name: Some("x".to_string()), + size: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: Some(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(42)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }), + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }, + Stmt { + kind: StmtKind::Return, + name: None, + size: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: Some(Expr { + kind: ExprKind::Ident, + value: None, + name: Some("x".to_string()), + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }), + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!( + result.is_valid(), + "Expected valid IR with defined variable, got errors: {:?}", + result.errors + ); + } +} diff --git a/exp/guppy-zlup/src/lib.rs b/exp/guppy-zlup/src/lib.rs new file mode 100644 index 000000000..c8fdc271a --- /dev/null +++ b/exp/guppy-zlup/src/lib.rs @@ -0,0 +1,438 @@ +//! # guppy-zlup +//! +//! Guppy linter and Zlup compiler. +//! +//! This crate provides: +//! - **Linting**: Validates Guppy quantum programs against NASA Power of 10 rules +//! - **Compilation**: Transforms validated Guppy IR into Zlup source code +//! +//! ## Usage +//! +//! ### Linting +//! +//! ```rust +//! use guppy_zlup::lint_source; +//! +//! // Lint source code +//! let result = lint_source("def main(): pass", None); +//! if result.has_errors { +//! for diag in &result.diagnostics { +//! println!("{}", diag); +//! } +//! } +//! assert!(!result.has_errors); +//! ``` +//! +//! To lint a file: +//! +//! ```rust,no_run +//! use guppy_zlup::lint_file; +//! +//! let result = lint_file("example.py").unwrap(); +//! ``` +//! +//! ### Compilation +//! +//! ```rust +//! use guppy_zlup::{compile, compile_to_ast}; +//! +//! let ir_json = r#"{ +//! "version": "0.1.0", +//! "functions": [{ +//! "name": "main", +//! "params": [], +//! "body": [ +//! {"kind": "qalloc", "name": "q", "size": {"kind": "literal", "value": 2}}, +//! {"kind": "gate", "gate": "h", "targets": [ +//! {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} +//! ]} +//! ] +//! }] +//! }"#; +//! +//! // Get Zlup source code directly +//! let source = compile(ir_json).unwrap(); +//! assert!(source.contains("fn main")); +//! +//! // Or get the Zlup AST for programmatic use +//! let ast = compile_to_ast(ir_json).unwrap(); +//! assert_eq!(ast.declarations.len(), 1); +//! ``` + +#![warn(clippy::all)] +#![allow(dead_code)] + +pub mod compiler; +pub mod ir; +pub mod linter; + +// Re-export zlup::ast for users who want to work with the AST +pub use zlup::ast as zlup_ast; + +// Re-export linter types +pub use linter::{Config, Diagnostic, LintResult, Linter, LowerError, OutputFormat, Severity}; + +// Re-export IR types +pub use ir::{GuppyIR, IrValidator, ValidationError, ValidationResult, validate_ir}; + +// Re-export compiler types +pub use compiler::{ParseError, TransformError, parse_ir}; + +/// Crate version +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +// ============================================================================= +// Linter API +// ============================================================================= + +/// Lint a source string and return diagnostics. +pub fn lint_source(source: &str, filename: Option<&str>) -> LintResult { + let config = Config::default(); + let linter = Linter::new(config); + linter.lint_source(source, filename.unwrap_or("")) +} + +/// Lint a file and return diagnostics. +pub fn lint_file(path: &str) -> Result { + let source = std::fs::read_to_string(path)?; + Ok(lint_source(&source, Some(path))) +} + +// ============================================================================= +// Compiler API +// ============================================================================= + +/// Compile Guppy IR JSON to Zlup AST. +/// +/// Use this when you need programmatic access to the Zlup AST, +/// e.g., for further transformation or analysis. +pub fn compile_to_ast(ir_json: &str) -> Result { + let ir = compiler::parse_ir(ir_json)?; + let zlup_ast = compiler::transform(&ir)?; + Ok(zlup_ast) +} + +/// Compile Guppy IR JSON to Zlup source code. +/// +/// This uses Zlup's canonical formatter for consistent output. +/// The output is validated by parsing it back through Zlup's parser. +pub fn compile(ir_json: &str) -> Result { + let zlup_ast = compile_to_ast(ir_json)?; + let options = zlup::pretty::PrettyOptions::default(); + let zlup_source = zlup::pretty::pretty_print(&zlup_ast, &options); + + // Validate the generated Zlup by parsing it back + validate_zlup(&zlup_source)?; + + Ok(zlup_source) +} + +/// Validate generated Zlup source by parsing and analyzing it. +/// +/// This ensures the generated code is syntactically and semantically valid. +pub fn validate_zlup(source: &str) -> Result<(), CompileError> { + // Parse the Zlup source + let program = zlup::parse(source).map_err(|e| { + CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Generated Zlup failed to parse: {}", + e + ))) + })?; + + // Run semantic analysis (permissive mode since we're validating generated code) + let mut analyzer = zlup::semantic::SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).map_err(|e| { + CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Generated Zlup failed semantic analysis: {}", + e + ))) + })?; + + Ok(()) +} + +/// Validate with round-trip: generated AST → source → parsed AST comparison. +/// +/// This ensures the pretty printer produces code that parses back to an equivalent AST. +pub fn validate_zlup_roundtrip( + original_ast: &zlup::ast::Program, + source: &str, +) -> Result<(), CompileError> { + // First do basic validation + validate_zlup(source)?; + + // Parse back + let reparsed = zlup::parse(source).map_err(|e| { + CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Round-trip: failed to reparse: {}", + e + ))) + })?; + + // Compare key structural properties + let original_fns: Vec<_> = original_ast + .declarations + .iter() + .filter_map(|d| match d { + zlup_ast::TopLevelDecl::Fn(f) => Some(f), + _ => None, + }) + .collect(); + + let reparsed_fns: Vec<_> = reparsed + .declarations + .iter() + .filter_map(|d| match d { + zlup_ast::TopLevelDecl::Fn(f) => Some(f), + _ => None, + }) + .collect(); + + if original_fns.len() != reparsed_fns.len() { + return Err(CompileError::Transform( + compiler::TransformError::ValidationFailed(format!( + "Round-trip: function count mismatch (original: {}, reparsed: {})", + original_fns.len(), + reparsed_fns.len() + )), + )); + } + + for (orig, repr) in original_fns.iter().zip(reparsed_fns.iter()) { + if orig.name != repr.name { + return Err(CompileError::Transform( + compiler::TransformError::ValidationFailed(format!( + "Round-trip: function name mismatch (original: {}, reparsed: {})", + orig.name, repr.name + )), + )); + } + if orig.params.len() != repr.params.len() { + return Err(CompileError::Transform( + compiler::TransformError::ValidationFailed(format!( + "Round-trip: parameter count mismatch for '{}' (original: {}, reparsed: {})", + orig.name, + orig.params.len(), + repr.params.len() + )), + )); + } + } + + Ok(()) +} + +/// Compile and validate with round-trip checking. +pub fn compile_with_roundtrip(ir_json: &str) -> Result { + let ir = compiler::parse_ir(ir_json)?; + let zlup_ast = compiler::transform(&ir)?; + let options = zlup::pretty::PrettyOptions::default(); + let zlup_source = zlup::pretty::pretty_print(&zlup_ast, &options); + + // Validate with round-trip + validate_zlup_roundtrip(&zlup_ast, &zlup_source)?; + + Ok(zlup_source) +} + +/// Compile Guppy IR from a file to Zlup source code. +pub fn compile_file(path: &str) -> Result { + let json = std::fs::read_to_string(path).map_err(CompileError::Io)?; + compile(&json) +} + +/// Compile Guppy IR from a file to Zlup AST. +pub fn compile_file_to_ast(path: &str) -> Result { + let json = std::fs::read_to_string(path).map_err(CompileError::Io)?; + compile_to_ast(&json) +} + +/// Compilation error. +#[derive(Debug, thiserror::Error)] +pub enum CompileError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Parse error: {0}")] + Parse(#[from] compiler::ParseError), + + #[error("Transform error: {0}")] + Transform(#[from] compiler::TransformError), +} + +// ============================================================================= +// Combined API +// ============================================================================= + +/// Lint and compile a Guppy source file to Zlup. +/// +/// This is the main entry point for the full pipeline: +/// 1. Parse the Guppy source +/// 2. Run lint checks +/// 3. Lower to IR +/// 4. Validate IR +/// 5. Transform to Zlup AST +/// 6. Pretty-print to Zlup source +/// 7. Validate generated Zlup +pub fn lint_and_compile(source: &str, filename: Option<&str>) -> Result { + let filename = filename.unwrap_or(""); + + // Run linter + let config = Config::default(); + let linter = Linter::new(config); + let result = linter.lint_source(source, filename); + + if result.has_errors { + return Err(PipelineError::LintErrors(result)); + } + + // Emit IR + let ir = ir::emit_ir(source, Some(filename)).map_err(PipelineError::Emit)?; + + // Validate IR + let validation = ir::validate_ir(&ir); + if !validation.is_valid() { + return Err(PipelineError::IrValidation(validation)); + } + + // Compile to Zlup + let ir_json = + serde_json::to_string(&ir).map_err(|e| PipelineError::Serialize(e.to_string()))?; + let zlup_source = compile(&ir_json)?; + + Ok(zlup_source) +} + +/// Pipeline error encompassing all stages. +#[derive(Debug, thiserror::Error)] +pub enum PipelineError { + #[error("Lint errors found")] + LintErrors(LintResult), + + #[error("IR emission error: {0}")] + Emit(#[from] ir::EmitError), + + #[error("IR validation errors: {0:?}")] + IrValidation(ir::ValidationResult), + + #[error("Serialization error: {0}")] + Serialize(String), + + #[error("Compilation error: {0}")] + Compile(#[from] CompileError), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() { + assert!(!VERSION.is_empty()); + } + + #[test] + fn test_lint_empty_source() { + let result = lint_source("", None); + assert!(result.is_ok(false)); + } + + #[test] + fn test_lint_simple_function() { + let source = r#" +def main() -> None: + pass +"#; + let result = lint_source(source, None); + assert!(!result.has_errors); + } + + #[test] + fn test_lint_while_true() { + let source = r#" +def main(): + while True: + pass +"#; + let result = lint_source(source, None); + assert!(result.has_errors); + assert!(result.diagnostics.iter().any(|d| d.rule_id == "ZLUP001")); + } + + #[test] + fn test_compile_simple() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "main", + "params": [], + "body": [ + {"kind": "qalloc", "name": "q", "size": {"kind": "literal", "value": 4}}, + {"kind": "gate", "gate": "h", "targets": [{"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}]} + ] + } + ] + }"#; + + let result = compile(ir); + assert!(result.is_ok(), "Compile failed: {:?}", result.err()); + let zlup = result.unwrap(); + assert!(zlup.contains("fn main")); + assert!(zlup.contains("qalloc")); + } + + #[test] + fn test_roundtrip_simple() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_func", + "params": [ + {"name": "x", "type": {"kind": "primitive", "name": "int"}} + ], + "return_type": {"kind": "primitive", "name": "int"}, + "body": [ + {"kind": "return", "return_value": {"kind": "ident", "name": "x"}} + ] + } + ] + }"#; + + let result = compile_with_roundtrip(ir); + assert!( + result.is_ok(), + "Round-trip compile failed: {:?}", + result.err() + ); + } + + #[test] + fn test_roundtrip_complex() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "quantum_ops", + "params": [], + "body": [ + {"kind": "qalloc", "name": "q", "size": {"kind": "literal", "value": 2}}, + {"kind": "gate", "gate": "h", "targets": [{"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}]}, + {"kind": "gate", "gate": "cx", "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ]} + ] + } + ] + }"#; + + let result = compile_with_roundtrip(ir); + assert!( + result.is_ok(), + "Round-trip compile failed: {:?}", + result.err() + ); + } +} diff --git a/exp/guppy-zlup/src/linter.rs b/exp/guppy-zlup/src/linter.rs new file mode 100644 index 000000000..8badf8752 --- /dev/null +++ b/exp/guppy-zlup/src/linter.rs @@ -0,0 +1,18 @@ +//! Guppy linter module. +//! +//! Validates Guppy quantum programs against NASA Power of 10 rules. + +pub mod ast; +pub mod config; +pub mod diagnostic; +pub mod engine; +pub mod lower; +pub mod noqa; +pub mod output; +pub mod rules; + +pub use config::Config; +pub use diagnostic::{Diagnostic, Severity}; +pub use engine::{LintResult, Linter}; +pub use lower::{LowerError, lower_source}; +pub use output::OutputFormat; diff --git a/exp/guppy-zlup/src/linter/ast.rs b/exp/guppy-zlup/src/linter/ast.rs new file mode 100644 index 000000000..b53feae94 --- /dev/null +++ b/exp/guppy-zlup/src/linter/ast.rs @@ -0,0 +1,590 @@ +//! Guppy AST - Clean Rust representation of Guppy programs. +//! +//! This module defines a Rust AST for Guppy quantum programs. It provides +//! a clean, typed representation that is independent of the Python parser. + +use serde::{Deserialize, Serialize}; + +/// A source span for error reporting. +#[derive(Debug, Clone, Copy, Default)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +impl Span { + pub fn new(start: usize, end: usize) -> Self { + Self { start, end } + } +} + +/// A Guppy module (compilation unit). +#[derive(Debug, Clone)] +pub struct Module { + pub functions: Vec, + pub span: Span, +} + +/// A function definition. +#[derive(Debug, Clone)] +pub struct Function { + pub name: String, + pub params: Vec, + pub return_type: Option, + pub body: Vec, + pub decorators: Vec, + pub span: Span, +} + +/// A function parameter. +#[derive(Debug, Clone)] +pub struct Param { + pub name: String, + pub ty: Option, + pub span: Span, +} + +/// Type expressions. +#[derive(Debug, Clone)] +pub enum Type { + /// Primitive types: int, float, bool, str, None + Primitive(PrimitiveType), + /// Qubit register: qubit[n] + Qubit { size: Option> }, + /// Array/list type: list[T] + Array { element: Box }, + /// Optional type: Optional[T] + Optional { inner: Box }, + /// Named/custom type + Named { name: String }, + /// Tuple type + Tuple { elements: Vec }, + /// Unknown/unresolved type + Unknown, +} + +/// Primitive types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PrimitiveType { + Int, + Float, + Bool, + Str, + None, +} + +/// Statements in Guppy. +#[derive(Debug, Clone)] +pub enum Stmt { + /// Qubit allocation: q = qubit[n] + Qalloc { + name: String, + size: Expr, + span: Span, + }, + + /// Quantum gate application: h(q[0]), cx(q[0], q[1]) + Gate { + gate: GateKind, + targets: Vec, + params: Vec, + span: Span, + }, + + /// Measurement: m = measure(q) + Measure { + target: Expr, + result: Option, + span: Span, + }, + + /// For loop: for i in range(n) + For { + var: String, + iter: ForIter, + body: Vec, + orelse: Vec, + span: Span, + }, + + /// While loop: while cond + While { + test: Expr, + body: Vec, + orelse: Vec, + span: Span, + }, + + /// If statement + If { + test: Expr, + body: Vec, + orelse: Vec, + span: Span, + }, + + /// Assignment: x = expr + Assign { + target: AssignTarget, + value: Expr, + span: Span, + }, + + /// Annotated assignment: x: T = expr + AnnAssign { + target: String, + annotation: Type, + value: Option, + span: Span, + }, + + /// Augmented assignment: x += expr + AugAssign { + target: AssignTarget, + op: BinOp, + value: Expr, + span: Span, + }, + + /// Return statement + Return { value: Option, span: Span }, + + /// Expression statement + Expr { value: Expr, span: Span }, + + /// Pass statement + Pass { span: Span }, + + /// Break statement + Break { span: Span }, + + /// Continue statement + Continue { span: Span }, + + /// Barrier (quantum synchronization) + Barrier { qubits: Vec, span: Span }, + + /// Try/except block + Try { + body: Vec, + handlers: Vec, + orelse: Vec, + finalbody: Vec, + span: Span, + }, + + /// Assert statement + Assert { + test: Expr, + msg: Option, + span: Span, + }, + + /// With statement (context manager) + With { + items: Vec, + body: Vec, + span: Span, + }, +} + +/// Exception handler. +#[derive(Debug, Clone)] +pub struct ExceptHandler { + pub ty: Option, + pub name: Option, + pub body: Vec, + pub span: Span, +} + +/// With item (context manager). +#[derive(Debug, Clone)] +pub struct WithItem { + pub context: Expr, + pub target: Option, + pub span: Span, +} + +/// Assignment target. +#[derive(Debug, Clone)] +pub enum AssignTarget { + /// Simple name: x + Name { name: String, span: Span }, + /// Subscript: x[i] + Subscript { + value: Box, + slice: Box, + span: Span, + }, + /// Attribute: x.attr + Attribute { + value: Box, + attr: String, + span: Span, + }, + /// Tuple unpacking: (a, b) + Tuple { elts: Vec, span: Span }, +} + +/// For loop iterator. +#[derive(Debug, Clone)] +pub enum ForIter { + /// range(end) or range(start, end) or range(start, end, step) + Range { + start: Option>, + end: Box, + step: Option>, + }, + /// Arbitrary iterable + Iter(Box), +} + +/// Expressions in Guppy. +#[derive(Debug, Clone)] +pub enum Expr { + /// Integer literal + IntLit { value: i64, span: Span }, + + /// Float literal + FloatLit { value: f64, span: Span }, + + /// String literal + StrLit { value: String, span: Span }, + + /// Boolean literal + BoolLit { value: bool, span: Span }, + + /// None literal + NoneLit { span: Span }, + + /// Identifier/name + Name { name: String, span: Span }, + + /// Subscript: a[i] + Subscript { + value: Box, + slice: Box, + span: Span, + }, + + /// Attribute access: a.b + Attribute { + value: Box, + attr: String, + span: Span, + }, + + /// Binary operation: a + b + BinOp { + left: Box, + op: BinOp, + right: Box, + span: Span, + }, + + /// Unary operation: -a, not a + UnaryOp { + op: UnaryOp, + operand: Box, + span: Span, + }, + + /// Comparison: a < b, a == b + Compare { + left: Box, + ops: Vec, + comparators: Vec, + span: Span, + }, + + /// Boolean operation: a and b, a or b + BoolOp { + op: BoolOpKind, + values: Vec, + span: Span, + }, + + /// Function call: f(a, b) + Call { + func: Box, + args: Vec, + keywords: Vec, + span: Span, + }, + + /// Conditional expression: a if cond else b + IfExp { + test: Box, + body: Box, + orelse: Box, + span: Span, + }, + + /// List literal: [a, b, c] + List { elts: Vec, span: Span }, + + /// Tuple literal: (a, b, c) + Tuple { elts: Vec, span: Span }, + + /// Dict literal: {a: b, c: d} + Dict { + keys: Vec>, + values: Vec, + span: Span, + }, + + /// Set literal: {a, b, c} + Set { elts: Vec, span: Span }, + + /// List comprehension: [x for x in xs if cond] + ListComp { + elt: Box, + generators: Vec, + span: Span, + }, + + /// Dict comprehension: {k: v for k, v in items} + DictComp { + key: Box, + value: Box, + generators: Vec, + span: Span, + }, + + /// Set comprehension: {x for x in xs} + SetComp { + elt: Box, + generators: Vec, + span: Span, + }, + + /// Generator expression: (x for x in xs) + GeneratorExp { + elt: Box, + generators: Vec, + span: Span, + }, + + /// Lambda: lambda x: x + 1 + Lambda { + params: Vec, + body: Box, + span: Span, + }, +} + +impl Expr { + /// Get the span of this expression. + pub fn span(&self) -> Span { + match self { + Expr::IntLit { span, .. } + | Expr::FloatLit { span, .. } + | Expr::StrLit { span, .. } + | Expr::BoolLit { span, .. } + | Expr::NoneLit { span } + | Expr::Name { span, .. } + | Expr::Subscript { span, .. } + | Expr::Attribute { span, .. } + | Expr::BinOp { span, .. } + | Expr::UnaryOp { span, .. } + | Expr::Compare { span, .. } + | Expr::BoolOp { span, .. } + | Expr::Call { span, .. } + | Expr::IfExp { span, .. } + | Expr::List { span, .. } + | Expr::Tuple { span, .. } + | Expr::Dict { span, .. } + | Expr::Set { span, .. } + | Expr::ListComp { span, .. } + | Expr::DictComp { span, .. } + | Expr::SetComp { span, .. } + | Expr::GeneratorExp { span, .. } + | Expr::Lambda { span, .. } => *span, + } + } +} + +/// Keyword argument. +#[derive(Debug, Clone)] +pub struct Keyword { + pub name: Option, + pub value: Expr, +} + +/// Comprehension clause. +#[derive(Debug, Clone)] +pub struct Comprehension { + pub target: AssignTarget, + pub iter: Expr, + pub ifs: Vec, + pub is_async: bool, +} + +/// Binary operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BinOp { + Add, + Sub, + Mult, + Div, + FloorDiv, + Mod, + Pow, + LShift, + RShift, + BitOr, + BitXor, + BitAnd, + MatMult, +} + +/// Unary operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnaryOp { + Invert, // ~ + Not, // not + UAdd, // + + USub, // - +} + +/// Comparison operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CmpOp { + Eq, + NotEq, + Lt, + LtE, + Gt, + GtE, + Is, + IsNot, + In, + NotIn, +} + +/// Boolean operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoolOpKind { + And, + Or, +} + +/// Quantum gate kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GateKind { + // Single-qubit gates + H, + X, + Y, + Z, + T, + Tdg, + S, + Sdg, + Sx, + Sy, + Sz, + // Parameterized single-qubit gates + Rx, + Ry, + Rz, + // Two-qubit gates + Cx, + Cy, + Cz, + Swap, + Iswap, + // Three-qubit gates + Ccx, + // Other operations + Pz, // Reset + // Generic/unknown gate + Custom(u32), // Index into a name table +} + +impl GateKind { + /// Parse a gate name into a GateKind. + pub fn from_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "h" | "hadamard" => Some(GateKind::H), + "x" | "pauli_x" => Some(GateKind::X), + "y" | "pauli_y" => Some(GateKind::Y), + "z" | "pauli_z" => Some(GateKind::Z), + "t" => Some(GateKind::T), + "tdg" | "t_dagger" => Some(GateKind::Tdg), + "s" => Some(GateKind::S), + "sdg" | "s_dagger" => Some(GateKind::Sdg), + "sx" | "sqrt_x" => Some(GateKind::Sx), + "sy" | "sqrt_y" => Some(GateKind::Sy), + "sz" | "sqrt_z" => Some(GateKind::Sz), + "rx" => Some(GateKind::Rx), + "ry" => Some(GateKind::Ry), + "rz" => Some(GateKind::Rz), + "cx" | "cnot" => Some(GateKind::Cx), + "cy" => Some(GateKind::Cy), + "cz" => Some(GateKind::Cz), + "swap" => Some(GateKind::Swap), + "iswap" => Some(GateKind::Iswap), + "ccx" | "toffoli" | "ccnot" => Some(GateKind::Ccx), + "pz" | "reset" => Some(GateKind::Pz), + _ => None, + } + } + + /// Check if this gate is parameterized. + pub fn is_parameterized(&self) -> bool { + matches!(self, GateKind::Rx | GateKind::Ry | GateKind::Rz) + } + + /// Get the number of qubits this gate operates on. + pub fn num_qubits(&self) -> usize { + match self { + GateKind::H + | GateKind::X + | GateKind::Y + | GateKind::Z + | GateKind::T + | GateKind::Tdg + | GateKind::S + | GateKind::Sdg + | GateKind::Sx + | GateKind::Sy + | GateKind::Sz + | GateKind::Rx + | GateKind::Ry + | GateKind::Rz + | GateKind::Pz => 1, + GateKind::Cx | GateKind::Cy | GateKind::Cz | GateKind::Swap | GateKind::Iswap => 2, + GateKind::Ccx => 3, + GateKind::Custom(_) => 0, // Unknown + } + } + + /// Get the name of this gate. + pub fn name(&self) -> &'static str { + match self { + GateKind::H => "h", + GateKind::X => "x", + GateKind::Y => "y", + GateKind::Z => "z", + GateKind::T => "t", + GateKind::Tdg => "tdg", + GateKind::S => "s", + GateKind::Sdg => "sdg", + GateKind::Sx => "sx", + GateKind::Sy => "sy", + GateKind::Sz => "sz", + GateKind::Rx => "rx", + GateKind::Ry => "ry", + GateKind::Rz => "rz", + GateKind::Cx => "cx", + GateKind::Cy => "cy", + GateKind::Cz => "cz", + GateKind::Swap => "swap", + GateKind::Iswap => "iswap", + GateKind::Ccx => "ccx", + GateKind::Pz => "pz", + GateKind::Custom(_) => "custom", + } + } +} diff --git a/exp/guppy-zlup/src/linter/config.rs b/exp/guppy-zlup/src/linter/config.rs new file mode 100644 index 000000000..399d6cb05 --- /dev/null +++ b/exp/guppy-zlup/src/linter/config.rs @@ -0,0 +1,192 @@ +//! Configuration for guppy-zlup. + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Linter configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Enabled lint rules (all rules enabled by default) + pub enabled_rules: Vec, + + /// Disabled lint rules (takes precedence over enabled_rules) + pub disabled_rules: Vec, + + /// Maximum cyclomatic complexity for ZLUP007 + pub max_complexity: u32, + + /// Treat warnings as errors + pub treat_warnings_as_errors: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + enabled_rules: vec![ + "ZLUP001".to_string(), + "ZLUP002".to_string(), + "ZLUP003".to_string(), + "ZLUP004".to_string(), + "ZLUP005".to_string(), + "ZLUP006".to_string(), + "ZLUP007".to_string(), + "ZLUP008".to_string(), + "ZLUP009".to_string(), + "ZLUP010".to_string(), + ], + disabled_rules: vec![], + max_complexity: 10, + treat_warnings_as_errors: false, + } + } +} + +impl Config { + /// Load configuration from a pyproject.toml file. + pub fn from_pyproject(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + + let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?; + let toml: toml::Value = toml::from_str(&content).map_err(ConfigError::Toml)?; + + let tool_config = toml + .get("tool") + .and_then(|t| t.get("guppy-zlup")) + .cloned() + .unwrap_or(toml::Value::Table(toml::map::Map::new())); + + // Extract values with defaults + let enabled_rules = tool_config + .get("enabled_rules") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(|| Self::default().enabled_rules); + + let disabled_rules = tool_config + .get("disabled_rules") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let max_complexity = tool_config + .get("max_complexity") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(10); + + let treat_warnings_as_errors = tool_config + .get("warnings_as_errors") + .or_else(|| tool_config.get("treat_warnings_as_errors")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Ok(Self { + enabled_rules, + disabled_rules, + max_complexity, + treat_warnings_as_errors, + }) + } + + /// Check if a rule is enabled. + /// + /// A rule is enabled if it's in enabled_rules AND not in disabled_rules. + /// disabled_rules takes precedence. + pub fn is_rule_enabled(&self, rule_id: &str) -> bool { + if self.disabled_rules.iter().any(|r| r == rule_id) { + return false; + } + self.enabled_rules.iter().any(|r| r == rule_id) + } + + /// Add a rule to the disabled list. + pub fn disable_rule(&mut self, rule_id: &str) { + if !self.disabled_rules.contains(&rule_id.to_string()) { + self.disabled_rules.push(rule_id.to_string()); + } + } + + /// Try to find a pyproject.toml by walking up from the given file path. + pub fn find_pyproject(start_path: &Path) -> Option { + let mut current = if start_path.is_file() { + start_path.parent()? + } else { + start_path + }; + + loop { + let candidate = current.join("pyproject.toml"); + if candidate.exists() { + return Some(candidate); + } + current = current.parent()?; + } + } +} + +/// Configuration error. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("TOML parse error: {0}")] + Toml(#[from] toml::de::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert!(config.is_rule_enabled("ZLUP001")); + assert!(config.is_rule_enabled("ZLUP007")); + assert_eq!(config.max_complexity, 10); + } + + #[test] + fn test_rule_enabled() { + let config = Config { + enabled_rules: vec!["ZLUP001".to_string(), "ZLUP002".to_string()], + ..Default::default() + }; + + assert!(config.is_rule_enabled("ZLUP001")); + assert!(config.is_rule_enabled("ZLUP002")); + assert!(!config.is_rule_enabled("ZLUP003")); + } + + #[test] + fn test_disabled_rules_take_precedence() { + let mut config = Config::default(); + // ZLUP001 is enabled by default + assert!(config.is_rule_enabled("ZLUP001")); + + // Disable it + config.disable_rule("ZLUP001"); + assert!(!config.is_rule_enabled("ZLUP001")); + + // Other rules still enabled + assert!(config.is_rule_enabled("ZLUP002")); + } + + #[test] + fn test_disable_rule_idempotent() { + let mut config = Config::default(); + config.disable_rule("ZLUP001"); + config.disable_rule("ZLUP001"); // Should not add duplicate + assert_eq!(config.disabled_rules.len(), 1); + } +} diff --git a/exp/guppy-zlup/src/linter/diagnostic.rs b/exp/guppy-zlup/src/linter/diagnostic.rs new file mode 100644 index 000000000..574484e49 --- /dev/null +++ b/exp/guppy-zlup/src/linter/diagnostic.rs @@ -0,0 +1,193 @@ +//! Diagnostic types for lint results. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Source location for error reporting. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, + pub end_line: Option, + pub end_column: Option, + pub file: Option, +} + +impl SourceLocation { + pub fn new(line: u32, column: u32) -> Self { + Self { + line, + column, + end_line: None, + end_column: None, + file: None, + } + } + + pub fn with_end(line: u32, column: u32, end_line: u32, end_column: u32) -> Self { + Self { + line, + column, + end_line: Some(end_line), + end_column: Some(end_column), + file: None, + } + } + + pub fn with_file(mut self, file: impl Into) -> Self { + self.file = Some(file.into()); + self + } +} + +impl fmt::Display for SourceLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.file { + Some(file) => write!(f, "{}:{}:{}", file, self.line, self.column), + None => write!(f, "{}:{}", self.line, self.column), + } + } +} + +/// Diagnostic severity levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Error, + Warning, + Info, + Hint, +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Severity::Error => write!(f, "error"), + Severity::Warning => write!(f, "warning"), + Severity::Info => write!(f, "info"), + Severity::Hint => write!(f, "hint"), + } + } +} + +/// A lint diagnostic/violation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Diagnostic { + pub rule_id: String, + pub message: String, + pub severity: Severity, + pub location: SourceLocation, + pub suggestion: Option, + /// The source code line(s) for context (not serialized) + #[serde(skip)] + pub source_context: Option, +} + +impl Diagnostic { + pub fn new( + rule_id: impl Into, + message: impl Into, + severity: Severity, + location: SourceLocation, + ) -> Self { + Self { + rule_id: rule_id.into(), + message: message.into(), + severity, + location, + suggestion: None, + source_context: None, + } + } + + pub fn with_suggestion(mut self, suggestion: impl Into) -> Self { + self.suggestion = Some(suggestion.into()); + self + } + + pub fn with_source_context(mut self, source: &str) -> Self { + let line_num = self.location.line as usize; + if line_num > 0 + && let Some(line) = source.lines().nth(line_num - 1) + { + self.source_context = Some(line.to_string()); + } + self + } + + pub fn error( + rule_id: impl Into, + message: impl Into, + location: SourceLocation, + ) -> Self { + Self::new(rule_id, message, Severity::Error, location) + } + + pub fn warning( + rule_id: impl Into, + message: impl Into, + location: SourceLocation, + ) -> Self { + Self::new(rule_id, message, Severity::Warning, location) + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}: [{}] {}", self.severity, self.rule_id, self.message)?; + writeln!(f, " --> {}", self.location)?; + + // Show source context if available + if let Some(ref source_line) = self.source_context { + let line_num = self.location.line; + let col = self.location.column.saturating_sub(1) as usize; + let line_num_width = line_num.to_string().len(); + + // Empty line number gutter + writeln!(f, "{:width$} |", "", width = line_num_width)?; + // Source line + writeln!(f, "{} | {}", line_num, source_line)?; + // Underline pointing to the location + let underline_len = if let Some(end_col) = self.location.end_column { + (end_col.saturating_sub(self.location.column) as usize).max(1) + } else { + 1 + }; + writeln!( + f, + "{:width$} | {:>col$}{}", + "", + "", + "^".repeat(underline_len), + width = line_num_width, + col = col + )?; + } + + if let Some(suggestion) = &self.suggestion { + writeln!(f, " help: {}", suggestion)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_diagnostic_display() { + let diag = Diagnostic::error( + "ZLUP001", + "'while True' creates an unbounded loop", + SourceLocation::new(5, 4).with_file("test.py"), + ) + .with_suggestion("Use a for loop with a fixed upper bound instead"); + + let output = format!("{}", diag); + assert!(output.contains("error")); + assert!(output.contains("ZLUP001")); + assert!(output.contains("while True")); + assert!(output.contains("test.py:5:4")); + } +} diff --git a/exp/guppy-zlup/src/linter/engine.rs b/exp/guppy-zlup/src/linter/engine.rs new file mode 100644 index 000000000..59b8795a4 --- /dev/null +++ b/exp/guppy-zlup/src/linter/engine.rs @@ -0,0 +1,191 @@ +//! Main linter engine for guppy-zlup. + +use std::fmt; + +use rustpython_parser::{Mode, parse}; + +use super::config::Config; +use super::diagnostic::{Diagnostic, Severity, SourceLocation}; +use super::noqa; +use super::rules::{self, LintRule}; + +/// Result of linting a file. +#[derive(Debug, Clone, Default)] +pub struct LintResult { + pub diagnostics: Vec, + pub has_errors: bool, + pub has_warnings: bool, +} + +impl LintResult { + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, diagnostic: Diagnostic) { + match diagnostic.severity { + Severity::Error => self.has_errors = true, + Severity::Warning => self.has_warnings = true, + _ => {} + } + self.diagnostics.push(diagnostic); + } + + pub fn is_ok(&self, treat_warnings_as_errors: bool) -> bool { + if self.has_errors { + return false; + } + if treat_warnings_as_errors && self.has_warnings { + return false; + } + true + } + + pub fn merge(&mut self, other: LintResult) { + self.has_errors |= other.has_errors; + self.has_warnings |= other.has_warnings; + self.diagnostics.extend(other.diagnostics); + } +} + +impl fmt::Display for LintResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.diagnostics.is_empty() { + return write!(f, "No issues found."); + } + for (i, diag) in self.diagnostics.iter().enumerate() { + if i > 0 { + writeln!(f)?; + } + write!(f, "{}", diag)?; + } + Ok(()) + } +} + +/// Main linter for Guppy programs. +pub struct Linter { + config: Config, + rules: Vec>, +} + +impl Linter { + pub fn new(config: Config) -> Self { + let rules = Self::load_rules(&config); + Self { config, rules } + } + + fn load_rules(config: &Config) -> Vec> { + let mut rules: Vec> = Vec::new(); + + if config.is_rule_enabled("ZLUP001") { + rules.push(Box::new(rules::ZLUP001UnboundedLoops)); + } + if config.is_rule_enabled("ZLUP002") { + rules.push(Box::new(rules::ZLUP002Recursion)); + } + if config.is_rule_enabled("ZLUP003") { + rules.push(Box::new(rules::ZLUP003DynamicAllocation)); + } + if config.is_rule_enabled("ZLUP004") { + rules.push(Box::new(rules::ZLUP004DynamicDispatch)); + } + if config.is_rule_enabled("ZLUP005") { + rules.push(Box::new(rules::ZLUP005UncheckedErrors)); + } + if config.is_rule_enabled("ZLUP006") { + rules.push(Box::new(rules::ZLUP006MissingTypes)); + } + if config.is_rule_enabled("ZLUP007") { + rules.push(Box::new(rules::ZLUP007ComplexControlFlow::new( + config.max_complexity, + ))); + } + if config.is_rule_enabled("ZLUP008") { + rules.push(Box::new(rules::ZLUP008CallDepth::default())); + } + if config.is_rule_enabled("ZLUP009") { + rules.push(Box::new(rules::ZLUP009AssertionDensity)); + } + if config.is_rule_enabled("ZLUP010") { + rules.push(Box::new(rules::ZLUP010GlobalState)); + } + + rules + } + + pub fn lint_source(&self, source: &str, filename: &str) -> LintResult { + let mut result = LintResult::new(); + + // Parse noqa directives first + let noqa_directives = noqa::parse_noqa(source); + + // Parse Python source + let parsed = match parse(source, Mode::Module, filename) { + Ok(p) => p, + Err(e) => { + result.add(Diagnostic::error( + "PARSE", + format!("Syntax error: {}", e), + SourceLocation::new(1, 0).with_file(filename), + )); + return result; + } + }; + + // Run each lint rule + for rule in &self.rules { + let diagnostics = rule.check(&parsed, filename, source); + for diag in diagnostics { + // Filter out diagnostics that are suppressed by noqa comments + if !noqa_directives.is_suppressed(diag.location.line, &diag.rule_id) { + result.add(diag); + } + } + } + + result + } + + pub fn lint_file(&self, path: &str) -> Result { + let source = std::fs::read_to_string(path)?; + Ok(self.lint_source(&source, path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lint_result_merge() { + let mut r1 = LintResult::new(); + r1.add(Diagnostic::error( + "TEST", + "error", + SourceLocation::new(1, 0), + )); + + let mut r2 = LintResult::new(); + r2.add(Diagnostic::warning( + "TEST", + "warning", + SourceLocation::new(2, 0), + )); + + r1.merge(r2); + assert!(r1.has_errors); + assert!(r1.has_warnings); + assert_eq!(r1.diagnostics.len(), 2); + } + + #[test] + fn test_lint_syntax_error() { + let config = Config::default(); + let linter = Linter::new(config); + let result = linter.lint_source("def foo(", "test.py"); + + assert!(result.has_errors); + assert!(result.diagnostics.iter().any(|d| d.rule_id == "PARSE")); + } +} diff --git a/exp/guppy-zlup/src/linter/lower.rs b/exp/guppy-zlup/src/linter/lower.rs new file mode 100644 index 000000000..0da92cfd7 --- /dev/null +++ b/exp/guppy-zlup/src/linter/lower.rs @@ -0,0 +1,962 @@ +//! Lower Python AST to Guppy AST. +//! +//! This module converts the rustpython-parser AST into our clean Guppy AST. +//! All Python-specific quirks are handled here, isolating the rest of the +//! codebase from parser API changes. + +use rustpython_parser::ast::{self, Constant, Expr as PyExpr, Mod, Ranged, Stmt as PyStmt}; + +use super::ast::{ + AssignTarget, BinOp, BoolOpKind, CmpOp, Comprehension, ExceptHandler, Expr, ForIter, Function, + GateKind, Keyword, Module, Param, PrimitiveType, Span, Stmt, Type, UnaryOp, WithItem, +}; + +/// Errors that can occur during lowering. +#[derive(Debug, Clone)] +pub enum LowerError { + /// The input was not a module. + NotAModule, + /// Unsupported Python construct. + Unsupported(String, Span), + /// Parse error from rustpython. + Parse(String), +} + +impl std::fmt::Display for LowerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LowerError::NotAModule => write!(f, "expected a module"), + LowerError::Unsupported(msg, _) => write!(f, "unsupported: {}", msg), + LowerError::Parse(msg) => write!(f, "parse error: {}", msg), + } + } +} + +impl std::error::Error for LowerError {} + +/// Lower Python source code to a Guppy AST. +pub fn lower_source(source: &str, filename: &str) -> Result { + let parsed = rustpython_parser::parse(source, rustpython_parser::Mode::Module, filename) + .map_err(|e| LowerError::Parse(e.to_string()))?; + + lower_mod(&parsed) +} + +/// Lower a Python module AST to a Guppy module. +pub fn lower_mod(parsed: &Mod) -> Result { + let Mod::Module(module) = parsed else { + return Err(LowerError::NotAModule); + }; + + let mut functions = Vec::new(); + + for stmt in &module.body { + if let PyStmt::FunctionDef(func) = stmt { + functions.push(lower_function(func)?); + } + // Skip other top-level statements for now (imports, classes, etc.) + } + + Ok(Module { + functions, + span: Span::default(), + }) +} + +/// Lower a Python function definition to a Guppy function. +fn lower_function(func: &ast::StmtFunctionDef) -> Result { + let span = make_span(func.range); + + let params = func + .args + .args + .iter() + .map(|arg| { + let ty = arg.def.annotation.as_ref().map(|ann| lower_type(ann)); + Param { + name: arg.def.arg.to_string(), + ty, + span: make_span(arg.def.range), + } + }) + .collect(); + + let return_type = func.returns.as_ref().map(|ann| lower_type(ann)); + + let body = func + .body + .iter() + .filter_map(|stmt| lower_stmt(stmt).ok()) + .collect(); + + let decorators = func + .decorator_list + .iter() + .filter_map(|d| { + if let PyExpr::Name(name) = d { + Some(name.id.to_string()) + } else { + None + } + }) + .collect(); + + Ok(Function { + name: func.name.to_string(), + params, + return_type, + body, + decorators, + span, + }) +} + +/// Lower a Python type annotation to a Guppy type. +fn lower_type(ann: &PyExpr) -> Type { + match ann { + PyExpr::Name(name) => { + let name_str = name.id.as_str(); + match name_str { + "int" => Type::Primitive(PrimitiveType::Int), + "float" => Type::Primitive(PrimitiveType::Float), + "bool" => Type::Primitive(PrimitiveType::Bool), + "str" => Type::Primitive(PrimitiveType::Str), + "None" => Type::Primitive(PrimitiveType::None), + "qubit" => Type::Qubit { size: None }, + _ => Type::Named { + name: name_str.to_string(), + }, + } + } + PyExpr::Subscript(sub) => { + if let PyExpr::Name(name) = sub.value.as_ref() { + let name_str = name.id.as_str(); + match name_str { + "list" | "List" => Type::Array { + element: Box::new(lower_type(&sub.slice)), + }, + "Optional" => Type::Optional { + inner: Box::new(lower_type(&sub.slice)), + }, + "qubit" => Type::Qubit { + size: lower_expr(&sub.slice).ok().map(Box::new), + }, + "Tuple" | "tuple" => { + if let PyExpr::Tuple(tuple) = sub.slice.as_ref() { + Type::Tuple { + elements: tuple.elts.iter().map(lower_type).collect(), + } + } else { + Type::Tuple { + elements: vec![lower_type(&sub.slice)], + } + } + } + _ => Type::Named { + name: name_str.to_string(), + }, + } + } else { + Type::Unknown + } + } + PyExpr::Constant(c) if matches!(c.value, Constant::None) => { + Type::Primitive(PrimitiveType::None) + } + _ => Type::Unknown, + } +} + +/// Lower a Python statement to a Guppy statement. +fn lower_stmt(stmt: &PyStmt) -> Result { + let span = stmt_span(stmt); + + match stmt { + PyStmt::Assign(assign) => lower_assign(assign), + + PyStmt::AnnAssign(ann) => { + let target = if let PyExpr::Name(name) = ann.target.as_ref() { + name.id.to_string() + } else { + return Err(LowerError::Unsupported( + "complex annotated assignment target".into(), + span, + )); + }; + + Ok(Stmt::AnnAssign { + target, + annotation: lower_type(&ann.annotation), + value: ann.value.as_ref().and_then(|v| lower_expr(v).ok()), + span, + }) + } + + PyStmt::AugAssign(aug) => { + let target = lower_assign_target(&aug.target)?; + let op = lower_operator(aug.op); + let value = lower_expr(&aug.value)?; + + Ok(Stmt::AugAssign { + target, + op, + value, + span, + }) + } + + PyStmt::Expr(expr_stmt) => { + let expr = &expr_stmt.value; + + // Check if this is a gate call + if let PyExpr::Call(call) = expr.as_ref() + && let Some(stmt) = try_lower_gate_call(call, span)? + { + return Ok(stmt); + } + + Ok(Stmt::Expr { + value: lower_expr(expr)?, + span, + }) + } + + PyStmt::For(for_stmt) => { + let var = if let PyExpr::Name(name) = for_stmt.target.as_ref() { + name.id.to_string() + } else { + return Err(LowerError::Unsupported("complex for target".into(), span)); + }; + + let iter = lower_for_iter(&for_stmt.iter)?; + + let body = for_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let orelse = for_stmt + .orelse + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::For { + var, + iter, + body, + orelse, + span, + }) + } + + PyStmt::While(while_stmt) => { + let test = lower_expr(&while_stmt.test)?; + + let body = while_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let orelse = while_stmt + .orelse + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::While { + test, + body, + orelse, + span, + }) + } + + PyStmt::If(if_stmt) => { + let test = lower_expr(&if_stmt.test)?; + + let body = if_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let orelse = if_stmt + .orelse + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::If { + test, + body, + orelse, + span, + }) + } + + PyStmt::Return(ret) => { + let value = ret.value.as_ref().and_then(|v| lower_expr(v).ok()); + Ok(Stmt::Return { value, span }) + } + + PyStmt::Pass(_) => Ok(Stmt::Pass { span }), + + PyStmt::Break(_) => Ok(Stmt::Break { span }), + + PyStmt::Continue(_) => Ok(Stmt::Continue { span }), + + PyStmt::Try(try_stmt) => { + let body = try_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let handlers = try_stmt + .handlers + .iter() + .map(|h| { + let ast::ExceptHandler::ExceptHandler(handler) = h; + ExceptHandler { + ty: handler.type_.as_ref().and_then(|t| lower_expr(t).ok()), + name: handler.name.as_ref().map(|n| n.to_string()), + body: handler + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(), + span: make_span(handler.range), + } + }) + .collect(); + + let orelse = try_stmt + .orelse + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let finalbody = try_stmt + .finalbody + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::Try { + body, + handlers, + orelse, + finalbody, + span, + }) + } + + PyStmt::Assert(assert_stmt) => { + let test = lower_expr(&assert_stmt.test)?; + let msg = assert_stmt.msg.as_ref().and_then(|m| lower_expr(m).ok()); + + Ok(Stmt::Assert { test, msg, span }) + } + + PyStmt::With(with_stmt) => { + let items = with_stmt + .items + .iter() + .filter_map(|item| { + let context = lower_expr(&item.context_expr).ok()?; + let target = item + .optional_vars + .as_ref() + .and_then(|v| lower_assign_target(v).ok()); + // Use the context expression's range for the span + Some(WithItem { + context, + target, + span: make_span(item.context_expr.range()), + }) + }) + .collect(); + + let body = with_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::With { items, body, span }) + } + + _ => Err(LowerError::Unsupported( + format!("statement: {:?}", std::mem::discriminant(stmt)), + span, + )), + } +} + +/// Try to lower a call expression as a gate or measurement. +fn try_lower_gate_call(call: &ast::ExprCall, span: Span) -> Result, LowerError> { + // Get the function name + let func_name = match call.func.as_ref() { + PyExpr::Name(name) => name.id.as_str(), + _ => return Ok(None), + }; + + // Check if it's a known gate + if let Some(gate) = GateKind::from_name(func_name) { + let targets = call + .args + .iter() + .map(lower_expr) + .collect::, _>>()?; + + return Ok(Some(Stmt::Gate { + gate, + targets, + params: Vec::new(), + span, + })); + } + + // Check if it's a measurement + if func_name == "measure" { + let target = call + .args + .first() + .map(lower_expr) + .transpose()? + .ok_or_else(|| LowerError::Unsupported("measure without target".into(), span))?; + + return Ok(Some(Stmt::Measure { + target, + result: None, + span, + })); + } + + // Check if it's a barrier + if func_name == "barrier" { + let qubits = call + .args + .iter() + .map(lower_expr) + .collect::, _>>()?; + + return Ok(Some(Stmt::Barrier { qubits, span })); + } + + Ok(None) +} + +/// Lower a Python assignment statement. +fn lower_assign(assign: &ast::StmtAssign) -> Result { + let span = make_span(assign.range); + + if assign.targets.len() != 1 { + return Err(LowerError::Unsupported( + "multiple assignment targets".into(), + span, + )); + } + + let target_expr = &assign.targets[0]; + + // Check for qubit allocation: q = qubit[n] + if let PyExpr::Subscript(sub) = assign.value.as_ref() + && let PyExpr::Name(name) = sub.value.as_ref() + && name.id.as_str() == "qubit" + { + let var_name = if let PyExpr::Name(n) = target_expr { + n.id.to_string() + } else { + return Err(LowerError::Unsupported( + "complex qalloc target".into(), + span, + )); + }; + + let size = lower_expr(&sub.slice)?; + + return Ok(Stmt::Qalloc { + name: var_name, + size, + span, + }); + } + + // Check for measurement: m = measure(q) + if let PyExpr::Call(call) = assign.value.as_ref() + && let PyExpr::Name(func_name) = call.func.as_ref() + && func_name.id.as_str() == "measure" + { + let result_name = if let PyExpr::Name(n) = target_expr { + n.id.to_string() + } else { + return Err(LowerError::Unsupported( + "complex measure target".into(), + span, + )); + }; + + let target = call + .args + .first() + .map(lower_expr) + .transpose()? + .ok_or_else(|| LowerError::Unsupported("measure without target".into(), span))?; + + return Ok(Stmt::Measure { + target, + result: Some(result_name), + span, + }); + } + + // Regular assignment + let target = lower_assign_target(target_expr)?; + let value = lower_expr(&assign.value)?; + + Ok(Stmt::Assign { + target, + value, + span, + }) +} + +/// Lower an assignment target. +fn lower_assign_target(expr: &PyExpr) -> Result { + let span = make_span(expr.range()); + + match expr { + PyExpr::Name(name) => Ok(AssignTarget::Name { + name: name.id.to_string(), + span, + }), + PyExpr::Subscript(sub) => Ok(AssignTarget::Subscript { + value: Box::new(lower_expr(&sub.value)?), + slice: Box::new(lower_expr(&sub.slice)?), + span, + }), + PyExpr::Attribute(attr) => Ok(AssignTarget::Attribute { + value: Box::new(lower_expr(&attr.value)?), + attr: attr.attr.to_string(), + span, + }), + PyExpr::Tuple(tuple) => Ok(AssignTarget::Tuple { + elts: tuple + .elts + .iter() + .map(lower_assign_target) + .collect::>()?, + span, + }), + _ => Err(LowerError::Unsupported( + "complex assignment target".into(), + span, + )), + } +} + +/// Lower a for loop iterator. +fn lower_for_iter(expr: &PyExpr) -> Result { + // Check for range(...) + if let PyExpr::Call(call) = expr + && let PyExpr::Name(name) = call.func.as_ref() + && name.id.as_str() == "range" + { + match call.args.len() { + 1 => { + return Ok(ForIter::Range { + start: None, + end: Box::new(lower_expr(&call.args[0])?), + step: None, + }); + } + 2 => { + return Ok(ForIter::Range { + start: Some(Box::new(lower_expr(&call.args[0])?)), + end: Box::new(lower_expr(&call.args[1])?), + step: None, + }); + } + 3 => { + return Ok(ForIter::Range { + start: Some(Box::new(lower_expr(&call.args[0])?)), + end: Box::new(lower_expr(&call.args[1])?), + step: Some(Box::new(lower_expr(&call.args[2])?)), + }); + } + _ => {} + } + } + + Ok(ForIter::Iter(Box::new(lower_expr(expr)?))) +} + +/// Lower a Python expression to a Guppy expression. +fn lower_expr(expr: &PyExpr) -> Result { + let span = make_span(expr.range()); + + match expr { + PyExpr::Constant(c) => match &c.value { + Constant::Int(i) => { + let value = i.to_string().parse::().unwrap_or(0); + Ok(Expr::IntLit { value, span }) + } + Constant::Float(f) => Ok(Expr::FloatLit { value: *f, span }), + Constant::Str(s) => Ok(Expr::StrLit { + value: s.clone(), + span, + }), + Constant::Bool(b) => Ok(Expr::BoolLit { value: *b, span }), + Constant::None => Ok(Expr::NoneLit { span }), + _ => Err(LowerError::Unsupported("constant type".into(), span)), + }, + + PyExpr::Name(name) => Ok(Expr::Name { + name: name.id.to_string(), + span, + }), + + PyExpr::Subscript(sub) => Ok(Expr::Subscript { + value: Box::new(lower_expr(&sub.value)?), + slice: Box::new(lower_expr(&sub.slice)?), + span, + }), + + PyExpr::Attribute(attr) => Ok(Expr::Attribute { + value: Box::new(lower_expr(&attr.value)?), + attr: attr.attr.to_string(), + span, + }), + + PyExpr::BinOp(binop) => Ok(Expr::BinOp { + left: Box::new(lower_expr(&binop.left)?), + op: lower_operator(binop.op), + right: Box::new(lower_expr(&binop.right)?), + span, + }), + + PyExpr::UnaryOp(unary) => Ok(Expr::UnaryOp { + op: lower_unary_op(unary.op), + operand: Box::new(lower_expr(&unary.operand)?), + span, + }), + + PyExpr::Compare(cmp) => { + let ops = cmp.ops.iter().map(|op| lower_cmp_op(*op)).collect(); + let comparators = cmp + .comparators + .iter() + .map(lower_expr) + .collect::>()?; + + Ok(Expr::Compare { + left: Box::new(lower_expr(&cmp.left)?), + ops, + comparators, + span, + }) + } + + PyExpr::BoolOp(boolop) => { + let op = match boolop.op { + ast::BoolOp::And => BoolOpKind::And, + ast::BoolOp::Or => BoolOpKind::Or, + }; + + let values = boolop + .values + .iter() + .map(lower_expr) + .collect::>()?; + + Ok(Expr::BoolOp { op, values, span }) + } + + PyExpr::Call(call) => { + let func = lower_expr(&call.func)?; + let args = call.args.iter().map(lower_expr).collect::>()?; + let keywords = call + .keywords + .iter() + .map(|kw| { + Ok(Keyword { + name: kw.arg.as_ref().map(|a| a.to_string()), + value: lower_expr(&kw.value)?, + }) + }) + .collect::>()?; + + Ok(Expr::Call { + func: Box::new(func), + args, + keywords, + span, + }) + } + + PyExpr::IfExp(ifexp) => Ok(Expr::IfExp { + test: Box::new(lower_expr(&ifexp.test)?), + body: Box::new(lower_expr(&ifexp.body)?), + orelse: Box::new(lower_expr(&ifexp.orelse)?), + span, + }), + + PyExpr::List(list) => { + let elts = list.elts.iter().map(lower_expr).collect::>()?; + Ok(Expr::List { elts, span }) + } + + PyExpr::Tuple(tuple) => { + let elts = tuple + .elts + .iter() + .map(lower_expr) + .collect::>()?; + Ok(Expr::Tuple { elts, span }) + } + + PyExpr::Dict(dict) => { + let keys = dict + .keys + .iter() + .map(|k| k.as_ref().and_then(|k| lower_expr(k).ok())) + .collect(); + let values = dict + .values + .iter() + .map(lower_expr) + .collect::>()?; + Ok(Expr::Dict { keys, values, span }) + } + + PyExpr::Set(set) => { + let elts = set.elts.iter().map(lower_expr).collect::>()?; + Ok(Expr::Set { elts, span }) + } + + PyExpr::ListComp(comp) => Ok(Expr::ListComp { + elt: Box::new(lower_expr(&comp.elt)?), + generators: comp + .generators + .iter() + .map(lower_comprehension) + .collect::>()?, + span, + }), + + PyExpr::DictComp(comp) => Ok(Expr::DictComp { + key: Box::new(lower_expr(&comp.key)?), + value: Box::new(lower_expr(&comp.value)?), + generators: comp + .generators + .iter() + .map(lower_comprehension) + .collect::>()?, + span, + }), + + PyExpr::SetComp(comp) => Ok(Expr::SetComp { + elt: Box::new(lower_expr(&comp.elt)?), + generators: comp + .generators + .iter() + .map(lower_comprehension) + .collect::>()?, + span, + }), + + PyExpr::GeneratorExp(gen_expr) => Ok(Expr::GeneratorExp { + elt: Box::new(lower_expr(&gen_expr.elt)?), + generators: gen_expr + .generators + .iter() + .map(lower_comprehension) + .collect::>()?, + span, + }), + + PyExpr::Lambda(lambda) => { + let params = lambda + .args + .args + .iter() + .map(|a| a.def.arg.to_string()) + .collect(); + Ok(Expr::Lambda { + params, + body: Box::new(lower_expr(&lambda.body)?), + span, + }) + } + + _ => Err(LowerError::Unsupported( + format!("expression: {:?}", std::mem::discriminant(expr)), + span, + )), + } +} + +/// Lower a comprehension clause. +fn lower_comprehension(comp: &ast::Comprehension) -> Result { + Ok(Comprehension { + target: lower_assign_target(&comp.target)?, + iter: lower_expr(&comp.iter)?, + ifs: comp.ifs.iter().filter_map(|e| lower_expr(e).ok()).collect(), + is_async: comp.is_async, + }) +} + +/// Lower a binary operator. +fn lower_operator(op: ast::Operator) -> BinOp { + match op { + ast::Operator::Add => BinOp::Add, + ast::Operator::Sub => BinOp::Sub, + ast::Operator::Mult => BinOp::Mult, + ast::Operator::Div => BinOp::Div, + ast::Operator::FloorDiv => BinOp::FloorDiv, + ast::Operator::Mod => BinOp::Mod, + ast::Operator::Pow => BinOp::Pow, + ast::Operator::LShift => BinOp::LShift, + ast::Operator::RShift => BinOp::RShift, + ast::Operator::BitOr => BinOp::BitOr, + ast::Operator::BitXor => BinOp::BitXor, + ast::Operator::BitAnd => BinOp::BitAnd, + ast::Operator::MatMult => BinOp::MatMult, + } +} + +/// Lower a unary operator. +fn lower_unary_op(op: ast::UnaryOp) -> UnaryOp { + match op { + ast::UnaryOp::Invert => UnaryOp::Invert, + ast::UnaryOp::Not => UnaryOp::Not, + ast::UnaryOp::UAdd => UnaryOp::UAdd, + ast::UnaryOp::USub => UnaryOp::USub, + } +} + +/// Lower a comparison operator. +fn lower_cmp_op(op: ast::CmpOp) -> CmpOp { + match op { + ast::CmpOp::Eq => CmpOp::Eq, + ast::CmpOp::NotEq => CmpOp::NotEq, + ast::CmpOp::Lt => CmpOp::Lt, + ast::CmpOp::LtE => CmpOp::LtE, + ast::CmpOp::Gt => CmpOp::Gt, + ast::CmpOp::GtE => CmpOp::GtE, + ast::CmpOp::Is => CmpOp::Is, + ast::CmpOp::IsNot => CmpOp::IsNot, + ast::CmpOp::In => CmpOp::In, + ast::CmpOp::NotIn => CmpOp::NotIn, + } +} + +/// Create a Span from a TextRange. +fn make_span(range: rustpython_parser::text_size::TextRange) -> Span { + Span { + start: range.start().into(), + end: range.end().into(), + } +} + +/// Get the span of a statement. +fn stmt_span(stmt: &PyStmt) -> Span { + match stmt { + PyStmt::FunctionDef(s) => make_span(s.range), + PyStmt::AsyncFunctionDef(s) => make_span(s.range), + PyStmt::ClassDef(s) => make_span(s.range), + PyStmt::Return(s) => make_span(s.range), + PyStmt::Delete(s) => make_span(s.range), + PyStmt::Assign(s) => make_span(s.range), + PyStmt::TypeAlias(s) => make_span(s.range), + PyStmt::AugAssign(s) => make_span(s.range), + PyStmt::AnnAssign(s) => make_span(s.range), + PyStmt::For(s) => make_span(s.range), + PyStmt::AsyncFor(s) => make_span(s.range), + PyStmt::While(s) => make_span(s.range), + PyStmt::If(s) => make_span(s.range), + PyStmt::With(s) => make_span(s.range), + PyStmt::AsyncWith(s) => make_span(s.range), + PyStmt::Match(s) => make_span(s.range), + PyStmt::Raise(s) => make_span(s.range), + PyStmt::Try(s) => make_span(s.range), + PyStmt::TryStar(s) => make_span(s.range), + PyStmt::Assert(s) => make_span(s.range), + PyStmt::Import(s) => make_span(s.range), + PyStmt::ImportFrom(s) => make_span(s.range), + PyStmt::Global(s) => make_span(s.range), + PyStmt::Nonlocal(s) => make_span(s.range), + PyStmt::Expr(s) => make_span(s.range), + PyStmt::Pass(s) => make_span(s.range), + PyStmt::Break(s) => make_span(s.range), + PyStmt::Continue(s) => make_span(s.range), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lower_simple_function() { + let source = r#" +def foo(x: int) -> int: + return x + 1 +"#; + let module = lower_source(source, "").unwrap(); + assert_eq!(module.functions.len(), 1); + assert_eq!(module.functions[0].name, "foo"); + assert_eq!(module.functions[0].params.len(), 1); + assert_eq!(module.functions[0].params[0].name, "x"); + } + + #[test] + fn test_lower_gate_call() { + let source = r#" +def bell(): + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) +"#; + let module = lower_source(source, "").unwrap(); + assert_eq!(module.functions.len(), 1); + + let body = &module.functions[0].body; + assert!(matches!(body[0], Stmt::Qalloc { .. })); + assert!(matches!( + body[1], + Stmt::Gate { + gate: GateKind::H, + .. + } + )); + assert!(matches!( + body[2], + Stmt::Gate { + gate: GateKind::Cx, + .. + } + )); + } + + #[test] + fn test_lower_for_range() { + let source = r#" +def loop(): + for i in range(10): + pass +"#; + let module = lower_source(source, "").unwrap(); + let body = &module.functions[0].body; + + if let Stmt::For { iter, .. } = &body[0] { + assert!(matches!(iter, ForIter::Range { start: None, .. })); + } else { + panic!("expected for loop"); + } + } +} diff --git a/exp/guppy-zlup/src/linter/noqa.rs b/exp/guppy-zlup/src/linter/noqa.rs new file mode 100644 index 000000000..a26cced1b --- /dev/null +++ b/exp/guppy-zlup/src/linter/noqa.rs @@ -0,0 +1,153 @@ +//! Support for inline disable comments (noqa). +//! +//! Supports: +//! - `# noqa` - suppress all warnings on this line +//! - `# noqa: ZLUP001` - suppress specific rule +//! - `# noqa: ZLUP001, ZLUP002` - suppress multiple rules +//! - `# type: ignore` - suppress type-related warnings (ZLUP006) + +use std::collections::{BTreeMap, BTreeSet}; + +use regex::Regex; + +/// Parsed noqa directives from source code. +#[derive(Debug, Default)] +pub struct NoqaDirectives { + /// Lines where all rules are suppressed (# noqa without specific rules). + pub suppress_all: BTreeSet, + + /// Map from line number to set of suppressed rule IDs (uppercase). + pub suppress_rules: BTreeMap>, + + /// File-level suppressions (rules suppressed for entire file, uppercase). + pub file_level: BTreeSet, +} + +impl NoqaDirectives { + /// Check if a diagnostic at the given line with the given rule should be suppressed. + pub fn is_suppressed(&self, line: u32, rule_id: &str) -> bool { + let rule_upper = rule_id.to_uppercase(); + + // Check file-level suppression + if self.file_level.contains(&rule_upper) || self.file_level.contains("*") { + return true; + } + + // Check line-level suppress all + if self.suppress_all.contains(&line) { + return true; + } + + // Check line-level rule suppression + if let Some(rules) = self.suppress_rules.get(&line) + && rules.contains(&rule_upper) + { + return true; + } + + false + } +} + +/// Parse noqa directives from source code. +pub fn parse_noqa(source: &str) -> NoqaDirectives { + let mut directives = NoqaDirectives::default(); + + // Regex patterns (case insensitive for noqa rules) + let noqa_pattern = Regex::new(r"(?i)#\s*noqa(?:\s*:\s*([A-Z0-9,\s]+))?").unwrap(); + let type_ignore_pattern = Regex::new(r"#\s*type:\s*ignore").unwrap(); + + for (line_idx, line) in source.lines().enumerate() { + let line_num = (line_idx + 1) as u32; + + // Check for # noqa comments + if let Some(captures) = noqa_pattern.captures(line) { + if let Some(rules_match) = captures.get(1) { + // Specific rules: # noqa: ZLUP001, ZLUP002 + let rules: BTreeSet = rules_match + .as_str() + .split(',') + .map(|s| s.trim().to_uppercase()) + .filter(|s| !s.is_empty()) + .collect(); + + // Check if this is a file-level directive (first non-empty, non-comment line area) + if line_idx < 5 && line.trim().starts_with('#') { + directives.file_level.extend(rules.clone()); + } + + directives + .suppress_rules + .entry(line_num) + .or_default() + .extend(rules); + } else { + // Suppress all: # noqa + directives.suppress_all.insert(line_num); + } + } + + // Check for # type: ignore (suppresses ZLUP006) + if type_ignore_pattern.is_match(line) { + directives + .suppress_rules + .entry(line_num) + .or_default() + .insert("ZLUP006".to_string()); + } + } + + directives +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_noqa_all() { + let source = "x = 1 / y # noqa"; + let directives = parse_noqa(source); + assert!(directives.suppress_all.contains(&1)); + assert!(directives.is_suppressed(1, "ZLUP005")); + } + + #[test] + fn test_noqa_specific() { + let source = "x = 1 / y # noqa: ZLUP005"; + let directives = parse_noqa(source); + assert!(directives.is_suppressed(1, "ZLUP005")); + assert!(!directives.is_suppressed(1, "ZLUP001")); + } + + #[test] + fn test_noqa_multiple() { + let source = "x = 1 / y # noqa: ZLUP005, ZLUP006"; + let directives = parse_noqa(source); + assert!(directives.is_suppressed(1, "ZLUP005")); + assert!(directives.is_suppressed(1, "ZLUP006")); + assert!(!directives.is_suppressed(1, "ZLUP001")); + } + + #[test] + fn test_type_ignore() { + let source = "def foo(x): # type: ignore"; + let directives = parse_noqa(source); + assert!(directives.is_suppressed(1, "ZLUP006")); + assert!(!directives.is_suppressed(1, "ZLUP001")); + } + + #[test] + fn test_not_suppressed() { + let source = "x = 1 / y"; + let directives = parse_noqa(source); + assert!(!directives.is_suppressed(1, "ZLUP005")); + } + + #[test] + fn test_case_insensitive() { + let source = "x = 1 / y # noqa: zlup005"; + let directives = parse_noqa(source); + assert!(directives.is_suppressed(1, "ZLUP005")); + } +} diff --git a/exp/guppy-zlup/src/linter/output.rs b/exp/guppy-zlup/src/linter/output.rs new file mode 100644 index 000000000..8a755feaa --- /dev/null +++ b/exp/guppy-zlup/src/linter/output.rs @@ -0,0 +1,320 @@ +//! Output formatters for lint results. + +use serde::Serialize; + +use super::diagnostic::{Diagnostic, Severity}; +use super::engine::LintResult; + +/// Output format for lint results. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutputFormat { + /// Human-readable text output (default). + #[default] + Text, + /// JSON output for machine parsing. + Json, + /// SARIF format for GitHub Actions integration. + Sarif, +} + +impl std::str::FromStr for OutputFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "text" => Ok(OutputFormat::Text), + "json" => Ok(OutputFormat::Json), + "sarif" => Ok(OutputFormat::Sarif), + _ => Err(format!( + "Unknown output format: '{}'. Valid options: text, json, sarif", + s + )), + } + } +} + +impl LintResult { + /// Format the lint result as JSON. + pub fn to_json(&self) -> String { + let output = JsonOutput { + diagnostics: &self.diagnostics, + summary: JsonSummary { + total: self.diagnostics.len(), + errors: self + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error)) + .count(), + warnings: self + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(), + has_errors: self.has_errors, + has_warnings: self.has_warnings, + }, + }; + serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()) + } + + /// Format the lint result as SARIF (Static Analysis Results Interchange Format). + pub fn to_sarif(&self, tool_name: &str, tool_version: &str) -> String { + let sarif = SarifReport::from_lint_result(self, tool_name, tool_version); + serde_json::to_string_pretty(&sarif).unwrap_or_else(|_| "{}".to_string()) + } + + /// Format the lint result according to the specified format. + pub fn format(&self, format: OutputFormat) -> String { + match format { + OutputFormat::Text => self.to_string(), + OutputFormat::Json => self.to_json(), + OutputFormat::Sarif => self.to_sarif("guppy-zlup", env!("CARGO_PKG_VERSION")), + } + } +} + +// JSON output structures + +#[derive(Serialize)] +struct JsonOutput<'a> { + diagnostics: &'a [Diagnostic], + summary: JsonSummary, +} + +#[derive(Serialize)] +struct JsonSummary { + total: usize, + errors: usize, + warnings: usize, + has_errors: bool, + has_warnings: bool, +} + +// SARIF output structures (version 2.1.0) + +#[derive(Serialize)] +struct SarifReport { + #[serde(rename = "$schema")] + schema: &'static str, + version: &'static str, + runs: Vec, +} + +#[derive(Serialize)] +struct SarifRun { + tool: SarifTool, + results: Vec, +} + +#[derive(Serialize)] +struct SarifTool { + driver: SarifDriver, +} + +#[derive(Serialize)] +struct SarifDriver { + name: String, + version: String, + #[serde(rename = "informationUri")] + information_uri: &'static str, + rules: Vec, +} + +#[derive(Serialize)] +struct SarifRule { + id: String, + name: String, + #[serde(rename = "shortDescription")] + short_description: SarifMessage, + #[serde(rename = "defaultConfiguration")] + default_configuration: SarifConfiguration, +} + +#[derive(Serialize)] +struct SarifConfiguration { + level: &'static str, +} + +#[derive(Serialize)] +struct SarifResult { + #[serde(rename = "ruleId")] + rule_id: String, + level: &'static str, + message: SarifMessage, + locations: Vec, +} + +#[derive(Serialize)] +struct SarifMessage { + text: String, +} + +#[derive(Serialize)] +struct SarifLocation { + #[serde(rename = "physicalLocation")] + physical_location: SarifPhysicalLocation, +} + +#[derive(Serialize)] +struct SarifPhysicalLocation { + #[serde(rename = "artifactLocation")] + artifact_location: SarifArtifactLocation, + region: SarifRegion, +} + +#[derive(Serialize)] +struct SarifArtifactLocation { + uri: String, +} + +#[derive(Serialize)] +struct SarifRegion { + #[serde(rename = "startLine")] + start_line: u32, + #[serde(rename = "startColumn")] + start_column: u32, + #[serde(rename = "endLine", skip_serializing_if = "Option::is_none")] + end_line: Option, + #[serde(rename = "endColumn", skip_serializing_if = "Option::is_none")] + end_column: Option, +} + +impl SarifReport { + fn from_lint_result(result: &LintResult, tool_name: &str, tool_version: &str) -> Self { + // Collect unique rules + let mut rules: Vec = Vec::new(); + let mut seen_rules: std::collections::HashSet = std::collections::HashSet::new(); + + for diag in &result.diagnostics { + if !seen_rules.contains(&diag.rule_id) { + seen_rules.insert(diag.rule_id.clone()); + rules.push(SarifRule { + id: diag.rule_id.clone(), + name: diag.rule_id.clone(), + short_description: SarifMessage { + text: get_rule_description(&diag.rule_id), + }, + default_configuration: SarifConfiguration { + level: severity_to_sarif_level(&diag.severity), + }, + }); + } + } + + // Convert diagnostics to SARIF results + let results: Vec = result + .diagnostics + .iter() + .map(|diag| SarifResult { + rule_id: diag.rule_id.clone(), + level: severity_to_sarif_level(&diag.severity), + message: SarifMessage { + text: diag.message.clone(), + }, + locations: vec![SarifLocation { + physical_location: SarifPhysicalLocation { + artifact_location: SarifArtifactLocation { + uri: diag.location.file.clone().unwrap_or_default(), + }, + region: SarifRegion { + start_line: diag.location.line, + start_column: diag.location.column, + end_line: diag.location.end_line, + end_column: diag.location.end_column, + }, + }, + }], + }) + .collect(); + + SarifReport { + schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + version: "2.1.0", + runs: vec![SarifRun { + tool: SarifTool { + driver: SarifDriver { + name: tool_name.to_string(), + version: tool_version.to_string(), + information_uri: "https://github.com/PECOS-packages/PECOS", + rules, + }, + }, + results, + }], + } + } +} + +fn severity_to_sarif_level(severity: &Severity) -> &'static str { + match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "note", + Severity::Hint => "note", + } +} + +fn get_rule_description(rule_id: &str) -> String { + match rule_id { + "ZLUP001" => "Unbounded loops are prohibited".to_string(), + "ZLUP002" => "Recursive function calls are prohibited".to_string(), + "ZLUP003" => "Dynamic allocation inside loops is prohibited".to_string(), + "ZLUP004" => "Dynamic dispatch is prohibited".to_string(), + "ZLUP005" => "Unchecked error conditions".to_string(), + "ZLUP006" => "Missing type annotations".to_string(), + "ZLUP007" => "Overly complex control flow".to_string(), + "ZLUP008" => "Excessive call depth".to_string(), + "ZLUP009" => "Missing assertions in non-trivial functions".to_string(), + "ZLUP010" => "Mutable global state".to_string(), + "PARSE" => "Syntax error".to_string(), + _ => format!("Rule {}", rule_id), + } +} + +#[cfg(test)] +mod tests { + use super::super::diagnostic::SourceLocation; + use super::*; + + #[test] + fn test_output_format_parse() { + assert_eq!("text".parse::().unwrap(), OutputFormat::Text); + assert_eq!("json".parse::().unwrap(), OutputFormat::Json); + assert_eq!( + "sarif".parse::().unwrap(), + OutputFormat::Sarif + ); + assert_eq!("JSON".parse::().unwrap(), OutputFormat::Json); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_json_output() { + let mut result = LintResult::new(); + result.add(Diagnostic::error( + "ZLUP001", + "test error", + SourceLocation::new(1, 1).with_file("test.py"), + )); + + let json = result.to_json(); + assert!(json.contains("ZLUP001")); + assert!(json.contains("test error")); + assert!(json.contains("\"errors\": 1")); + } + + #[test] + fn test_sarif_output() { + let mut result = LintResult::new(); + result.add(Diagnostic::error( + "ZLUP001", + "test error", + SourceLocation::new(1, 1).with_file("test.py"), + )); + + let sarif = result.to_sarif("guppy-zlup", "0.1.0"); + assert!(sarif.contains("ZLUP001")); + assert!(sarif.contains("test.py")); + assert!(sarif.contains("\"version\": \"2.1.0\"")); + } +} diff --git a/exp/guppy-zlup/src/linter/rules.rs b/exp/guppy-zlup/src/linter/rules.rs new file mode 100644 index 000000000..b10e9e7ce --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules.rs @@ -0,0 +1,77 @@ +//! Lint rules for guppy-zlup. + +mod zlup001; +mod zlup002; +mod zlup003; +mod zlup004; +mod zlup005; +mod zlup006; +mod zlup007; +mod zlup008; +mod zlup009; +mod zlup010; + +pub use zlup001::ZLUP001UnboundedLoops; +pub use zlup002::ZLUP002Recursion; +pub use zlup003::ZLUP003DynamicAllocation; +pub use zlup004::ZLUP004DynamicDispatch; +pub use zlup005::ZLUP005UncheckedErrors; +pub use zlup006::ZLUP006MissingTypes; +pub use zlup007::ZLUP007ComplexControlFlow; +pub use zlup008::ZLUP008CallDepth; +pub use zlup009::ZLUP009AssertionDensity; +pub use zlup010::ZLUP010GlobalState; + +use rustpython_parser::ast::Mod; +use rustpython_parser::text_size::TextRange; + +use super::diagnostic::{Diagnostic, Severity, SourceLocation}; + +/// Trait for lint rules. +pub trait LintRule: Send + Sync { + /// Rule identifier (e.g., "ZLUP001"). + fn id(&self) -> &'static str; + + /// Human-readable rule name. + fn name(&self) -> &'static str; + + /// Full description of the rule. + fn description(&self) -> &'static str; + + /// Default severity level. + fn severity(&self) -> Severity; + + /// Check an AST for violations of this rule. + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec; +} + +/// Convert a byte offset to (line, column) using the source text. +fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { + let mut line = 1; + let mut col = 1; + for (i, ch) in source.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (line, col) +} + +/// Helper to create a source location from a TextRange. +pub fn make_location(range: TextRange, filename: &str, source: &str) -> SourceLocation { + let (line, column) = offset_to_line_col(source, range.start().into()); + let (end_line, end_column) = offset_to_line_col(source, range.end().into()); + SourceLocation { + line: line as u32, + column: column as u32, + end_line: Some(end_line as u32), + end_column: Some(end_column as u32), + file: Some(filename.to_string()), + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup001.rs b/exp/guppy-zlup/src/linter/rules/zlup001.rs new file mode 100644 index 000000000..3a1c184ab --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup001.rs @@ -0,0 +1,265 @@ +//! ZLUP001: Detect unbounded loops. + +use rustpython_parser::ast::{self, Constant, Expr, Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Detects unbounded loops that violate NASA Power of 10 rules. +pub struct ZLUP001UnboundedLoops; + +impl LintRule for ZLUP001UnboundedLoops { + fn id(&self) -> &'static str { + "ZLUP001" + } + + fn name(&self) -> &'static str { + "unbounded-loops" + } + + fn description(&self) -> &'static str { + "All loops must have a fixed upper bound. Unbounded loops \ + (while True, while with non-constant condition) are prohibited." + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + if let Mod::Module(module) = parsed { + for stmt in &module.body { + check_stmt(stmt, filename, source, &mut diagnostics); + } + } + + diagnostics + } +} + +fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { + match stmt { + Stmt::While(while_stmt) => { + check_while_loop(while_stmt, filename, source, diagnostics); + // Recursively check body + for s in &while_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::For(for_stmt) => { + for s in &for_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::If(if_stmt) => { + for s in &if_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(h) = handler; + for s in &h.body { + check_stmt(s, filename, source, diagnostics); + } + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_while_loop( + while_stmt: &ast::StmtWhile, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + let test = &while_stmt.test; + + // Check for `while True:` + if let Expr::Constant(c) = test.as_ref() { + if matches!(c.value, Constant::Bool(true)) { + // Only report if there's no unconditional break + if !has_unconditional_break(&while_stmt.body) { + diagnostics.push( + Diagnostic::error( + "ZLUP001", + "'while True' creates an unbounded loop", + make_location(while_stmt.range, filename, source), + ) + .with_suggestion("Use a for loop with a fixed upper bound instead") + .with_source_context(source), + ); + } + return; + } + + // Check for `while 1:` + if let Constant::Int(ref i) = c.value + && (i.to_u32_digits().1 == [1] || i.to_string() == "1") + { + // Only report if there's no unconditional break + if !has_unconditional_break(&while_stmt.body) { + diagnostics.push( + Diagnostic::error( + "ZLUP001", + "'while 1' creates an unbounded loop", + make_location(while_stmt.range, filename, source), + ) + .with_suggestion("Use a for loop with a fixed upper bound instead") + .with_source_context(source), + ); + } + return; + } + } + + // Check for while loops without a clear termination condition + if !has_bounded_condition(test) && !has_unconditional_break(&while_stmt.body) { + diagnostics.push( + Diagnostic::error( + "ZLUP001", + "while loop may be unbounded", + make_location(while_stmt.range, filename, source), + ) + .with_suggestion( + "Consider using a for loop with range() or add a fixed iteration limit", + ) + .with_source_context(source), + ); + } +} + +fn has_bounded_condition(test: &Expr) -> bool { + match test { + // Comparisons against variables that might change are acceptable + Expr::Compare(_) => true, + // Boolean variables are acceptable (might become False) + Expr::Name(_) => true, + // Binary boolean operations + Expr::BoolOp(op) => op.values.iter().all(has_bounded_condition), + // Unary not + Expr::UnaryOp(unary) if matches!(unary.op, ast::UnaryOp::Not) => { + has_bounded_condition(&unary.operand) + } + _ => false, + } +} + +fn has_unconditional_break(body: &[Stmt]) -> bool { + for stmt in body { + if matches!(stmt, Stmt::Break(_)) { + return true; + } + // Check for guaranteed break in all branches of an if + if let Stmt::If(if_stmt) = stmt + && has_unconditional_break(&if_stmt.body) + && has_unconditional_break(&if_stmt.orelse) + { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP001UnboundedLoops.check(&parsed, "", source) + } + + #[test] + fn test_while_true() { + let diagnostics = check_source( + r#" +while True: + pass +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("while True")); + } + + #[test] + fn test_while_one() { + let diagnostics = check_source( + r#" +while 1: + pass +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("while 1")); + } + + #[test] + fn test_while_with_comparison() { + let diagnostics = check_source( + r#" +x = 10 +while x > 0: + x -= 1 +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_while_with_break() { + let diagnostics = check_source( + r#" +while True: + if condition: + break + else: + break +"#, + ); + // Has unconditional break in both branches + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup002.rs b/exp/guppy-zlup/src/linter/rules/zlup002.rs new file mode 100644 index 000000000..268a79909 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup002.rs @@ -0,0 +1,349 @@ +//! ZLUP002: Detect recursive function calls. + +use std::collections::{HashMap, HashSet}; + +use rustpython_parser::ast::{self, Expr, Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Detects recursive function calls (call graph cycles). +pub struct ZLUP002Recursion; + +impl LintRule for ZLUP002Recursion { + fn id(&self) -> &'static str { + "ZLUP002" + } + + fn name(&self) -> &'static str { + "recursion" + } + + fn description(&self) -> &'static str { + "Recursive function calls are prohibited. All call graphs must be acyclic \ + to ensure bounded stack usage and predictable execution." + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + // Collect function definitions + let mut functions: HashMap = HashMap::new(); + for stmt in &module.body { + if let Stmt::FunctionDef(func) = stmt { + functions.insert(func.name.to_string(), func); + } + } + + // Build call graph + let mut call_graph: HashMap> = HashMap::new(); + for (name, func) in &functions { + let calls = collect_calls(&func.body, &functions); + call_graph.insert(name.clone(), calls); + } + + // Detect cycles + let mut reported: HashSet = HashSet::new(); + for func_name in functions.keys() { + if reported.contains(func_name) { + continue; + } + + if let Some(cycle) = find_cycle(func_name, &call_graph) { + let func = functions.get(func_name).unwrap(); + + if cycle.len() == 1 { + // Direct recursion + diagnostics.push( + Diagnostic::error( + "ZLUP002", + format!("function '{}' calls itself (direct recursion)", func_name), + make_location(func.range, filename, source), + ) + .with_suggestion( + "Replace recursion with iteration using a loop with a fixed upper bound", + ) + .with_source_context(source), + ); + } else { + // Indirect recursion + let cycle_str = cycle + .iter() + .chain(std::iter::once(&cycle[0])) + .cloned() + .collect::>() + .join(" -> "); + + diagnostics.push( + Diagnostic::error( + "ZLUP002", + format!( + "function '{}' is part of a recursive cycle: {}", + func_name, cycle_str + ), + make_location(func.range, filename, source), + ) + .with_suggestion( + "Break the cycle by restructuring the call graph or using iteration", + ) + .with_source_context(source), + ); + } + + // Mark all functions in the cycle as reported + for name in &cycle { + reported.insert(name.clone()); + } + } + } + + diagnostics + } +} + +fn collect_calls( + body: &[Stmt], + known_funcs: &HashMap, +) -> HashSet { + let mut calls = HashSet::new(); + + for stmt in body { + collect_calls_from_stmt(stmt, known_funcs, &mut calls); + } + + calls +} + +fn collect_calls_from_stmt( + stmt: &Stmt, + known_funcs: &HashMap, + calls: &mut HashSet, +) { + match stmt { + Stmt::Expr(expr_stmt) => { + collect_calls_from_expr(&expr_stmt.value, known_funcs, calls); + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + collect_calls_from_expr(value, known_funcs, calls); + } + } + Stmt::Assign(assign) => { + collect_calls_from_expr(&assign.value, known_funcs, calls); + } + Stmt::AugAssign(aug) => { + collect_calls_from_expr(&aug.value, known_funcs, calls); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + collect_calls_from_expr(value, known_funcs, calls); + } + } + Stmt::For(for_stmt) => { + collect_calls_from_expr(&for_stmt.iter, known_funcs, calls); + for s in &for_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &for_stmt.orelse { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + Stmt::While(while_stmt) => { + collect_calls_from_expr(&while_stmt.test, known_funcs, calls); + for s in &while_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &while_stmt.orelse { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + Stmt::If(if_stmt) => { + collect_calls_from_expr(&if_stmt.test, known_funcs, calls); + for s in &if_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &if_stmt.orelse { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &try_stmt.orelse { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &try_stmt.finalbody { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + _ => {} + } +} + +fn collect_calls_from_expr( + expr: &Expr, + known_funcs: &HashMap, + calls: &mut HashSet, +) { + match expr { + Expr::Call(call) => { + if let Expr::Name(name) = call.func.as_ref() { + let func_name = name.id.to_string(); + if known_funcs.contains_key(&func_name) { + calls.insert(func_name); + } + } + // Recurse into arguments + for arg in &call.args { + collect_calls_from_expr(arg, known_funcs, calls); + } + } + Expr::BinOp(binop) => { + collect_calls_from_expr(&binop.left, known_funcs, calls); + collect_calls_from_expr(&binop.right, known_funcs, calls); + } + Expr::UnaryOp(unary) => { + collect_calls_from_expr(&unary.operand, known_funcs, calls); + } + Expr::Compare(cmp) => { + collect_calls_from_expr(&cmp.left, known_funcs, calls); + for comparator in &cmp.comparators { + collect_calls_from_expr(comparator, known_funcs, calls); + } + } + Expr::BoolOp(boolop) => { + for value in &boolop.values { + collect_calls_from_expr(value, known_funcs, calls); + } + } + Expr::IfExp(ifexp) => { + collect_calls_from_expr(&ifexp.test, known_funcs, calls); + collect_calls_from_expr(&ifexp.body, known_funcs, calls); + collect_calls_from_expr(&ifexp.orelse, known_funcs, calls); + } + Expr::List(list) => { + for elt in &list.elts { + collect_calls_from_expr(elt, known_funcs, calls); + } + } + Expr::Tuple(tuple) => { + for elt in &tuple.elts { + collect_calls_from_expr(elt, known_funcs, calls); + } + } + Expr::Subscript(sub) => { + collect_calls_from_expr(&sub.value, known_funcs, calls); + collect_calls_from_expr(&sub.slice, known_funcs, calls); + } + _ => {} + } +} + +fn find_cycle(start: &str, graph: &HashMap>) -> Option> { + let mut visited: HashSet = HashSet::new(); + let mut path: Vec = Vec::new(); + + fn dfs( + node: &str, + graph: &HashMap>, + visited: &mut HashSet, + path: &mut Vec, + ) -> Option> { + if let Some(pos) = path.iter().position(|n| n == node) { + // Found a cycle + return Some(path[pos..].to_vec()); + } + + if visited.contains(node) { + return None; + } + + visited.insert(node.to_string()); + path.push(node.to_string()); + + if let Some(neighbors) = graph.get(node) { + for neighbor in neighbors { + if let Some(cycle) = dfs(neighbor, graph, visited, path) { + return Some(cycle); + } + } + } + + path.pop(); + None + } + + dfs(start, graph, &mut visited, &mut path) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP002Recursion.check(&parsed, "", source) + } + + #[test] + fn test_direct_recursion() { + let diagnostics = check_source( + r#" +def factorial(n): + if n <= 1: + return 1 + return n * factorial(n - 1) +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("direct recursion")); + } + + #[test] + fn test_indirect_recursion() { + let diagnostics = check_source( + r#" +def foo(): + bar() + +def bar(): + foo() +"#, + ); + // Should detect a cycle + assert!(!diagnostics.is_empty()); + assert!(diagnostics[0].message.contains("recursive cycle")); + } + + #[test] + fn test_no_recursion() { + let diagnostics = check_source( + r#" +def foo(): + pass + +def bar(): + foo() + +def baz(): + bar() +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup003.rs b/exp/guppy-zlup/src/linter/rules/zlup003.rs new file mode 100644 index 000000000..a4ff7cb37 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup003.rs @@ -0,0 +1,320 @@ +//! ZLUP003: Detect dynamic allocation inside loops. + +use rustpython_parser::ast::{self, Expr, Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Functions/types that perform allocation. +const ALLOCATION_FUNCTIONS: &[&str] = &[ + "qalloc", + "qubit", + "list", + "dict", + "set", + "bytearray", + "array", +]; + +/// Methods that may cause allocation. +const ALLOCATION_METHODS: &[&str] = &["append", "extend", "insert", "copy"]; + +/// Detects dynamic allocation inside loop bodies. +pub struct ZLUP003DynamicAllocation; + +impl LintRule for ZLUP003DynamicAllocation { + fn id(&self) -> &'static str { + "ZLUP003" + } + + fn name(&self) -> &'static str { + "dynamic-allocation" + } + + fn description(&self) -> &'static str { + "Dynamic allocation inside loops is prohibited. All allocations \ + should be performed at initialization time with fixed sizes." + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, 0, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + loop_depth: usize, + diagnostics: &mut Vec, +) { + match stmt { + Stmt::For(for_stmt) => { + for s in &for_stmt.body { + check_stmt(s, filename, source, loop_depth + 1, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::While(while_stmt) => { + for s in &while_stmt.body { + check_stmt(s, filename, source, loop_depth + 1, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, 0, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, 0, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, 0, diagnostics); + } + } + Stmt::If(if_stmt) => { + for s in &if_stmt.body { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(h) = handler; + for s in &h.body { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::Expr(expr_stmt) if loop_depth > 0 => { + check_expr(&expr_stmt.value, filename, source, diagnostics); + } + Stmt::Assign(assign) if loop_depth > 0 => { + check_expr(&assign.value, filename, source, diagnostics); + } + Stmt::AugAssign(aug) if loop_depth > 0 => { + check_expr(&aug.value, filename, source, diagnostics); + } + Stmt::AnnAssign(ann) => { + if loop_depth > 0 + && let Some(value) = &ann.value + { + check_expr(value, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_expr(expr: &Expr, filename: &str, source: &str, diagnostics: &mut Vec) { + match expr { + Expr::Call(call) => { + // Check for direct allocation calls: list(), qalloc(), etc. + if let Expr::Name(name) = call.func.as_ref() { + let func_name = name.id.as_str(); + if ALLOCATION_FUNCTIONS.contains(&func_name) { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + format!("'{}()' allocates memory inside a loop", func_name), + make_location(call.range, filename, source), + ) + .with_suggestion( + "Move allocation outside the loop and reuse or pre-allocate with a fixed size", + ) + .with_source_context(source), + ); + } + } + + // Check for allocation methods: list.append(), etc. + if let Expr::Attribute(attr) = call.func.as_ref() { + let method_name = attr.attr.as_str(); + if ALLOCATION_METHODS.contains(&method_name) { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + format!("'.{}()' may allocate memory inside a loop", method_name), + make_location(call.range, filename, source), + ) + .with_suggestion( + "Pre-allocate the collection with sufficient capacity before the loop", + ) + .with_source_context(source), + ); + } + } + + // Recurse into arguments + for arg in &call.args { + check_expr(arg, filename, source, diagnostics); + } + } + + Expr::Subscript(sub) => { + // Check for qubit[n] allocation + if let Expr::Name(name) = sub.value.as_ref() + && name.id.as_str() == "qubit" + { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "'qubit[...]' allocates qubits inside a loop", + make_location(sub.range, filename, source), + ) + .with_suggestion("Allocate qubits outside the loop") + .with_source_context(source), + ); + } + } + + Expr::ListComp(comp) => { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "List comprehension allocates memory inside a loop", + make_location(comp.range, filename, source), + ) + .with_suggestion("Pre-compute the list outside the loop") + .with_source_context(source), + ); + } + + Expr::DictComp(comp) => { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "Dict comprehension allocates memory inside a loop", + make_location(comp.range, filename, source), + ) + .with_suggestion("Pre-compute the dict outside the loop") + .with_source_context(source), + ); + } + + Expr::SetComp(comp) => { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "Set comprehension allocates memory inside a loop", + make_location(comp.range, filename, source), + ) + .with_suggestion("Pre-compute the set outside the loop") + .with_source_context(source), + ); + } + + // Recurse into sub-expressions + Expr::BinOp(binop) => { + check_expr(&binop.left, filename, source, diagnostics); + check_expr(&binop.right, filename, source, diagnostics); + } + Expr::UnaryOp(unary) => { + check_expr(&unary.operand, filename, source, diagnostics); + } + Expr::IfExp(ifexp) => { + check_expr(&ifexp.test, filename, source, diagnostics); + check_expr(&ifexp.body, filename, source, diagnostics); + check_expr(&ifexp.orelse, filename, source, diagnostics); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP003DynamicAllocation.check(&parsed, "", source) + } + + #[test] + fn test_list_in_loop() { + let diagnostics = check_source( + r#" +for i in range(10): + x = list() +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("list()")); + } + + #[test] + fn test_append_in_loop() { + let diagnostics = check_source( + r#" +result = [] +for i in range(10): + result.append(i) +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains(".append()")); + } + + #[test] + fn test_list_comprehension_in_loop() { + let diagnostics = check_source( + r#" +for i in range(10): + x = [j for j in range(5)] +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("List comprehension")); + } + + #[test] + fn test_allocation_outside_loop() { + let diagnostics = check_source( + r#" +x = list() +for i in range(10): + pass +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup004.rs b/exp/guppy-zlup/src/linter/rules/zlup004.rs new file mode 100644 index 000000000..981d0f8db --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup004.rs @@ -0,0 +1,300 @@ +//! ZLUP004: Detect dynamic dispatch and runtime evaluation. + +use rustpython_parser::ast::{Expr, Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Functions that enable dynamic dispatch or code execution. +const DYNAMIC_FUNCTIONS: &[(&str, &str)] = &[ + ("eval", "executes arbitrary code at runtime"), + ("exec", "executes arbitrary code at runtime"), + ("compile", "compiles code at runtime"), + ("getattr", "dynamically resolves attributes"), + ("setattr", "dynamically sets attributes"), + ("delattr", "dynamically deletes attributes"), + ("hasattr", "dynamically checks attributes"), + ("__import__", "dynamically imports modules"), +]; + +/// Detects dynamic dispatch and runtime code execution. +pub struct ZLUP004DynamicDispatch; + +impl LintRule for ZLUP004DynamicDispatch { + fn id(&self) -> &'static str { + "ZLUP004" + } + + fn name(&self) -> &'static str { + "dynamic-dispatch" + } + + fn description(&self) -> &'static str { + "Dynamic dispatch and runtime code execution are prohibited. \ + All function calls and attribute accesses must be statically determinable." + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { + match stmt { + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::For(for_stmt) => { + check_expr(&for_stmt.iter, filename, source, diagnostics); + for s in &for_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::While(while_stmt) => { + check_expr(&while_stmt.test, filename, source, diagnostics); + for s in &while_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::If(if_stmt) => { + check_expr(&if_stmt.test, filename, source, diagnostics); + for s in &if_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::Expr(expr_stmt) => { + check_expr(&expr_stmt.value, filename, source, diagnostics); + } + Stmt::Assign(assign) => { + check_expr(&assign.value, filename, source, diagnostics); + } + Stmt::AugAssign(aug) => { + check_expr(&aug.value, filename, source, diagnostics); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + check_expr(value, filename, source, diagnostics); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + check_expr(value, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_expr(expr: &Expr, filename: &str, source: &str, diagnostics: &mut Vec) { + match expr { + Expr::Call(call) => { + // Check for calls to dynamic functions + if let Expr::Name(name) = call.func.as_ref() { + let func_name = name.id.as_str(); + if let Some((_, reason)) = DYNAMIC_FUNCTIONS.iter().find(|(n, _)| *n == func_name) { + let suggestion = get_suggestion(func_name); + diagnostics.push( + Diagnostic::error( + "ZLUP004", + format!("'{}()' {}", func_name, reason), + make_location(call.range, filename, source), + ) + .with_suggestion(suggestion) + .with_source_context(source), + ); + } + } + + // Check for calling subscripted expressions (dynamic dispatch) + if let Expr::Subscript(_) = call.func.as_ref() { + diagnostics.push( + Diagnostic::error( + "ZLUP004", + "Calling a subscripted expression creates dynamic dispatch", + make_location(call.range, filename, source), + ) + .with_suggestion("Use explicit function calls or match statements instead") + .with_source_context(source), + ); + } + + // Recurse into callee and arguments + check_expr(&call.func, filename, source, diagnostics); + for arg in &call.args { + check_expr(arg, filename, source, diagnostics); + } + } + + // Recurse into sub-expressions + Expr::BinOp(binop) => { + check_expr(&binop.left, filename, source, diagnostics); + check_expr(&binop.right, filename, source, diagnostics); + } + Expr::UnaryOp(unary) => { + check_expr(&unary.operand, filename, source, diagnostics); + } + Expr::Compare(cmp) => { + check_expr(&cmp.left, filename, source, diagnostics); + for comparator in &cmp.comparators { + check_expr(comparator, filename, source, diagnostics); + } + } + Expr::BoolOp(boolop) => { + for value in &boolop.values { + check_expr(value, filename, source, diagnostics); + } + } + Expr::IfExp(ifexp) => { + check_expr(&ifexp.test, filename, source, diagnostics); + check_expr(&ifexp.body, filename, source, diagnostics); + check_expr(&ifexp.orelse, filename, source, diagnostics); + } + Expr::List(list) => { + for elt in &list.elts { + check_expr(elt, filename, source, diagnostics); + } + } + Expr::Tuple(tuple) => { + for elt in &tuple.elts { + check_expr(elt, filename, source, diagnostics); + } + } + Expr::Dict(dict) => { + for key in dict.keys.iter().flatten() { + check_expr(key, filename, source, diagnostics); + } + for value in &dict.values { + check_expr(value, filename, source, diagnostics); + } + } + Expr::Subscript(sub) => { + check_expr(&sub.value, filename, source, diagnostics); + check_expr(&sub.slice, filename, source, diagnostics); + } + Expr::Attribute(attr) => { + check_expr(&attr.value, filename, source, diagnostics); + } + _ => {} + } +} + +fn get_suggestion(func_name: &str) -> &'static str { + match func_name { + "eval" | "exec" | "compile" => "Use direct function calls or predefined operations", + "getattr" => "Use direct attribute access (obj.attr) or a dictionary lookup", + "setattr" => "Use direct attribute assignment (obj.attr = value)", + "delattr" => "Use direct attribute deletion (del obj.attr)", + "hasattr" => "Use direct attribute access with try/except or a dictionary", + "__import__" => "Use regular import statements", + _ => "Avoid dynamic dispatch", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP004DynamicDispatch.check(&parsed, "", source) + } + + #[test] + fn test_eval() { + let diagnostics = check_source( + r#" +result = eval("1 + 2") +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("eval()")); + } + + #[test] + fn test_getattr() { + let diagnostics = check_source( + r#" +value = getattr(obj, "attr") +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("getattr()")); + } + + #[test] + fn test_subscript_call() { + let diagnostics = check_source( + r#" +func_table = {"a": func_a, "b": func_b} +func_table["a"]() +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("subscripted expression")); + } + + #[test] + fn test_normal_function_call() { + let diagnostics = check_source( + r#" +def foo(): + pass + +foo() +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup005.rs b/exp/guppy-zlup/src/linter/rules/zlup005.rs new file mode 100644 index 000000000..548b76a06 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup005.rs @@ -0,0 +1,308 @@ +//! ZLUP005: Detect unchecked error conditions. + +use rustpython_parser::ast::{self, Constant, Expr, Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Functions that may raise exceptions and should be wrapped in try/except. +const FUNCTIONS_THAT_MAY_RAISE: &[(&str, &str)] = &[ + ("open", "may raise FileNotFoundError or PermissionError"), + ("int", "may raise ValueError"), + ("float", "may raise ValueError"), +]; + +/// Detects potentially unchecked error conditions. +pub struct ZLUP005UncheckedErrors; + +impl LintRule for ZLUP005UncheckedErrors { + fn id(&self) -> &'static str { + "ZLUP005" + } + + fn name(&self) -> &'static str { + "unchecked-errors" + } + + fn description(&self) -> &'static str { + "Operations that may fail should have explicit error handling. \ + Assertions should be used to validate preconditions." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, false, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + in_try: bool, + diagnostics: &mut Vec, +) { + match stmt { + Stmt::Try(try_stmt) => { + // Inside try block, errors are handled + for s in &try_stmt.body { + check_stmt(s, filename, source, true, diagnostics); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(h) = handler; + for s in &h.body { + check_stmt(s, filename, source, false, diagnostics); + } + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, false, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, false, diagnostics); + } + } + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, false, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, false, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, false, diagnostics); + } + } + Stmt::For(for_stmt) => { + check_expr(&for_stmt.iter, filename, source, in_try, diagnostics); + for s in &for_stmt.body { + check_stmt(s, filename, source, in_try, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, in_try, diagnostics); + } + } + Stmt::While(while_stmt) => { + check_expr(&while_stmt.test, filename, source, in_try, diagnostics); + for s in &while_stmt.body { + check_stmt(s, filename, source, in_try, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, in_try, diagnostics); + } + } + Stmt::If(if_stmt) => { + check_expr(&if_stmt.test, filename, source, in_try, diagnostics); + for s in &if_stmt.body { + check_stmt(s, filename, source, in_try, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, in_try, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, in_try, diagnostics); + } + } + Stmt::Expr(expr_stmt) => { + check_expr(&expr_stmt.value, filename, source, in_try, diagnostics); + } + Stmt::Assign(assign) => { + check_expr(&assign.value, filename, source, in_try, diagnostics); + } + Stmt::AugAssign(aug) => { + check_expr(&aug.value, filename, source, in_try, diagnostics); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + check_expr(value, filename, source, in_try, diagnostics); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + check_expr(value, filename, source, in_try, diagnostics); + } + } + _ => {} + } +} + +fn check_expr( + expr: &Expr, + filename: &str, + source: &str, + in_try: bool, + diagnostics: &mut Vec, +) { + if in_try { + // Inside try block, don't report + return; + } + + match expr { + Expr::BinOp(binop) => { + // Check for division + if matches!( + binop.op, + ast::Operator::Div | ast::Operator::FloorDiv | ast::Operator::Mod + ) { + // Check if divisor is a non-zero literal + let is_safe = if let Expr::Constant(c) = binop.right.as_ref() { + match &c.value { + Constant::Int(i) => { + // Check if not zero + i.to_u32_digits().1.first() != Some(&0) + || !i.to_u32_digits().1.is_empty() + } + Constant::Float(f) => *f != 0.0, + _ => false, + } + } else { + false + }; + + if !is_safe { + let op_name = match binop.op { + ast::Operator::Div => "division", + ast::Operator::FloorDiv => "floor division", + ast::Operator::Mod => "modulo", + _ => "operation", + }; + diagnostics.push( + Diagnostic::warning( + "ZLUP005", + format!("Unchecked {} may raise ZeroDivisionError", op_name), + make_location(binop.range, filename, source), + ) + .with_suggestion("Add an assertion or check that divisor is non-zero") + .with_source_context(source), + ); + } + } + + check_expr(&binop.left, filename, source, in_try, diagnostics); + check_expr(&binop.right, filename, source, in_try, diagnostics); + } + + Expr::Call(call) => { + // Check for calls to functions that may raise + if let Expr::Name(name) = call.func.as_ref() { + let func_name = name.id.as_str(); + if let Some((_, reason)) = FUNCTIONS_THAT_MAY_RAISE + .iter() + .find(|(n, _)| *n == func_name) + { + diagnostics.push( + Diagnostic::warning( + "ZLUP005", + format!("'{}()' {}", func_name, reason), + make_location(call.range, filename, source), + ) + .with_suggestion("Wrap in try/except or validate input first") + .with_source_context(source), + ); + } + } + + // Recurse into arguments + for arg in &call.args { + check_expr(arg, filename, source, in_try, diagnostics); + } + } + + // Recurse into sub-expressions + Expr::UnaryOp(unary) => { + check_expr(&unary.operand, filename, source, in_try, diagnostics); + } + Expr::Compare(cmp) => { + check_expr(&cmp.left, filename, source, in_try, diagnostics); + for comparator in &cmp.comparators { + check_expr(comparator, filename, source, in_try, diagnostics); + } + } + Expr::BoolOp(boolop) => { + for value in &boolop.values { + check_expr(value, filename, source, in_try, diagnostics); + } + } + Expr::IfExp(ifexp) => { + check_expr(&ifexp.test, filename, source, in_try, diagnostics); + check_expr(&ifexp.body, filename, source, in_try, diagnostics); + check_expr(&ifexp.orelse, filename, source, in_try, diagnostics); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP005UncheckedErrors.check(&parsed, "", source) + } + + #[test] + fn test_division() { + let diagnostics = check_source( + r#" +result = a / b +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("division")); + } + + #[test] + fn test_division_by_literal() { + let diagnostics = check_source( + r#" +result = a / 2 +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_int_conversion() { + let diagnostics = check_source( + r#" +x = int(user_input) +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("int()")); + } + + #[test] + fn test_in_try_block() { + let diagnostics = check_source( + r#" +try: + x = int(user_input) +except ValueError: + x = 0 +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup006.rs b/exp/guppy-zlup/src/linter/rules/zlup006.rs new file mode 100644 index 000000000..cf0de62e4 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup006.rs @@ -0,0 +1,306 @@ +//! ZLUP006: Detect missing type annotations. + +use rustpython_parser::ast::{Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Detects missing type annotations on function signatures. +pub struct ZLUP006MissingTypes; + +impl LintRule for ZLUP006MissingTypes { + fn id(&self) -> &'static str { + "ZLUP006" + } + + fn name(&self) -> &'static str { + "missing-types" + } + + fn description(&self) -> &'static str { + "All function parameters and return types should have explicit \ + type annotations for Zlup compilation." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { + match stmt { + Stmt::FunctionDef(func) => { + check_function(func, filename, source, diagnostics); + // Check nested functions + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + check_async_function(func, filename, source, diagnostics); + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::If(if_stmt) => { + for s in &if_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::For(for_stmt) => { + for s in &for_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::While(while_stmt) => { + for s in &while_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_function( + func: &rustpython_parser::ast::StmtFunctionDef, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + let func_name = func.name.as_str(); + + // Skip dunder methods and test functions + if func_name.starts_with("__") || func_name.starts_with("test_") { + return; + } + + // Check parameters (func.args instead of func.parameters) + for arg in &func.args.args { + let arg_name = arg.def.arg.as_str(); + + // Skip 'self' and 'cls' + if arg_name == "self" || arg_name == "cls" { + continue; + } + + if arg.def.annotation.is_none() { + diagnostics.push( + Diagnostic::warning( + "ZLUP006", + format!("Parameter '{}' is missing type annotation", arg_name), + make_location(func.range, filename, source), + ) + .with_suggestion(format!("Add type annotation: {}: ", arg_name)) + .with_source_context(source), + ); + } + } + + // Check return type + if func.returns.is_none() && !is_trivial_body(&func.body) { + diagnostics.push( + Diagnostic::warning( + "ZLUP006", + format!("Function '{}' is missing return type annotation", func_name), + make_location(func.range, filename, source), + ) + .with_suggestion(format!( + "Add return type: def {}(...) -> :", + func_name + )) + .with_source_context(source), + ); + } +} + +fn check_async_function( + func: &rustpython_parser::ast::StmtAsyncFunctionDef, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + let func_name = func.name.as_str(); + + // Skip dunder methods and test functions + if func_name.starts_with("__") || func_name.starts_with("test_") { + return; + } + + // Check parameters (func.args instead of func.parameters) + for arg in &func.args.args { + let arg_name = arg.def.arg.as_str(); + + if arg_name == "self" || arg_name == "cls" { + continue; + } + + if arg.def.annotation.is_none() { + diagnostics.push( + Diagnostic::warning( + "ZLUP006", + format!("Parameter '{}' is missing type annotation", arg_name), + make_location(func.range, filename, source), + ) + .with_suggestion(format!("Add type annotation: {}: ", arg_name)) + .with_source_context(source), + ); + } + } + + // Check return type + if func.returns.is_none() && !is_trivial_body(&func.body) { + diagnostics.push( + Diagnostic::warning( + "ZLUP006", + format!( + "Async function '{}' is missing return type annotation", + func_name + ), + make_location(func.range, filename, source), + ) + .with_suggestion(format!( + "Add return type: async def {}(...) -> :", + func_name + )) + .with_source_context(source), + ); + } +} + +fn is_trivial_body(body: &[Stmt]) -> bool { + if body.is_empty() { + return true; + } + if body.len() == 1 { + match &body[0] { + Stmt::Pass(_) => return true, + Stmt::Expr(expr_stmt) => { + // Check for docstring or ellipsis + if let rustpython_parser::ast::Expr::Constant(c) = expr_stmt.value.as_ref() { + match &c.value { + rustpython_parser::ast::Constant::Str(_) => return true, + rustpython_parser::ast::Constant::Ellipsis => return true, + _ => {} + } + } + } + _ => {} + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP006MissingTypes.check(&parsed, "", source) + } + + #[test] + fn test_missing_param_type() { + let diagnostics = check_source( + r#" +def foo(x): + return x + 1 +"#, + ); + assert!(!diagnostics.is_empty()); + assert!( + diagnostics + .iter() + .any(|d| d.message.contains("Parameter 'x'")) + ); + } + + #[test] + fn test_missing_return_type() { + let diagnostics = check_source( + r#" +def foo(x: int): + return x + 1 +"#, + ); + assert!(!diagnostics.is_empty()); + assert!( + diagnostics + .iter() + .any(|d| d.message.contains("return type")) + ); + } + + #[test] + fn test_fully_typed() { + let diagnostics = check_source( + r#" +def foo(x: int) -> int: + return x + 1 +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_stub_function() { + let diagnostics = check_source( + r#" +def foo(x): + pass +"#, + ); + // Still warns about parameter but not return (trivial body) + assert!(diagnostics.iter().any(|d| d.message.contains("Parameter"))); + assert!( + !diagnostics + .iter() + .any(|d| d.message.contains("return type")) + ); + } + + #[test] + fn test_skip_self() { + let diagnostics = check_source( + r#" +class Foo: + def bar(self, x: int) -> int: + return x +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup007.rs b/exp/guppy-zlup/src/linter/rules/zlup007.rs new file mode 100644 index 000000000..fba593a7e --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup007.rs @@ -0,0 +1,366 @@ +//! ZLUP007: Detect overly complex control flow. + +use rustpython_parser::ast::{self, Expr, Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Detects functions with overly complex control flow. +pub struct ZLUP007ComplexControlFlow { + max_complexity: u32, +} + +impl ZLUP007ComplexControlFlow { + pub fn new(max_complexity: u32) -> Self { + Self { max_complexity } + } +} + +impl LintRule for ZLUP007ComplexControlFlow { + fn id(&self) -> &'static str { + "ZLUP007" + } + + fn name(&self) -> &'static str { + "complex-control-flow" + } + + fn description(&self) -> &'static str { + "Functions should have a cyclomatic complexity below the threshold. \ + High complexity makes code harder to analyze and test." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt( + stmt, + filename, + source, + self.max_complexity, + &mut diagnostics, + ); + } + + diagnostics + } +} + +fn check_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + max_complexity: u32, + diagnostics: &mut Vec, +) { + match stmt { + Stmt::FunctionDef(func) => { + let complexity = calculate_complexity(&func.body); + if complexity > max_complexity { + diagnostics.push( + Diagnostic::warning( + "ZLUP007", + format!( + "Function '{}' has cyclomatic complexity {} (max: {})", + func.name, complexity, max_complexity + ), + make_location(func.range, filename, source), + ) + .with_suggestion( + "Break the function into smaller functions or simplify the control flow", + ) + .with_source_context(source), + ); + } + // Check nested functions + for s in &func.body { + check_stmt(s, filename, source, max_complexity, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + let complexity = calculate_complexity(&func.body); + if complexity > max_complexity { + diagnostics.push( + Diagnostic::warning( + "ZLUP007", + format!( + "Async function '{}' has cyclomatic complexity {} (max: {})", + func.name, complexity, max_complexity + ), + make_location(func.range, filename, source), + ) + .with_suggestion( + "Break the function into smaller functions or simplify the control flow", + ) + .with_source_context(source), + ); + } + for s in &func.body { + check_stmt(s, filename, source, max_complexity, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, max_complexity, diagnostics); + } + } + _ => {} + } +} + +/// Calculate cyclomatic complexity of a function body. +/// +/// Complexity = 1 + number of decision points +/// +/// Decision points: +/// - if/elif +/// - for/while loops +/// - and/or in boolean expressions +/// - except handlers +/// - assert statements +/// - ternary expressions (if expressions) +/// - comprehensions with if clauses +/// - match cases +fn calculate_complexity(body: &[Stmt]) -> u32 { + let mut decision_points = 0; + + for stmt in body { + decision_points += count_decision_points_stmt(stmt); + } + + 1 + decision_points +} + +fn count_decision_points_stmt(stmt: &Stmt) -> u32 { + let mut count = 0; + + match stmt { + Stmt::If(if_stmt) => { + count += 1; // The if itself + count += count_decision_points_expr(&if_stmt.test); + for s in &if_stmt.body { + count += count_decision_points_stmt(s); + } + for s in &if_stmt.orelse { + count += count_decision_points_stmt(s); + } + } + Stmt::For(for_stmt) => { + count += 1; // The for loop + for s in &for_stmt.body { + count += count_decision_points_stmt(s); + } + for s in &for_stmt.orelse { + count += count_decision_points_stmt(s); + } + } + Stmt::While(while_stmt) => { + count += 1; // The while loop + count += count_decision_points_expr(&while_stmt.test); + for s in &while_stmt.body { + count += count_decision_points_stmt(s); + } + for s in &while_stmt.orelse { + count += count_decision_points_stmt(s); + } + } + Stmt::Try(try_stmt) => { + count += try_stmt.handlers.len() as u32; // Each handler is a decision point + for s in &try_stmt.body { + count += count_decision_points_stmt(s); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(h) = handler; + for s in &h.body { + count += count_decision_points_stmt(s); + } + } + for s in &try_stmt.orelse { + count += count_decision_points_stmt(s); + } + for s in &try_stmt.finalbody { + count += count_decision_points_stmt(s); + } + } + Stmt::Assert(_) => { + count += 1; // Assert is a decision point + } + Stmt::Match(match_stmt) => { + count += match_stmt.cases.len() as u32; // Each case is a decision point + for case in &match_stmt.cases { + for s in &case.body { + count += count_decision_points_stmt(s); + } + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + count += count_decision_points_stmt(s); + } + } + Stmt::Expr(expr_stmt) => { + count += count_decision_points_expr(&expr_stmt.value); + } + Stmt::Assign(assign) => { + count += count_decision_points_expr(&assign.value); + } + Stmt::AugAssign(aug) => { + count += count_decision_points_expr(&aug.value); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + count += count_decision_points_expr(value); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + count += count_decision_points_expr(value); + } + } + _ => {} + } + + count +} + +fn count_decision_points_expr(expr: &Expr) -> u32 { + let mut count = 0; + + match expr { + Expr::BoolOp(boolop) => { + // Each 'and' or 'or' adds (len(values) - 1) decision points + count += (boolop.values.len() - 1) as u32; + for value in &boolop.values { + count += count_decision_points_expr(value); + } + } + Expr::IfExp(_) => { + count += 1; // Ternary expression + } + Expr::ListComp(comp) => { + for generator in &comp.generators { + count += generator.ifs.len() as u32; + } + } + Expr::SetComp(comp) => { + for generator in &comp.generators { + count += generator.ifs.len() as u32; + } + } + Expr::DictComp(comp) => { + for generator in &comp.generators { + count += generator.ifs.len() as u32; + } + } + Expr::GeneratorExp(gen_expr) => { + for generator in &gen_expr.generators { + count += generator.ifs.len() as u32; + } + } + Expr::Call(call) => { + for arg in &call.args { + count += count_decision_points_expr(arg); + } + } + Expr::BinOp(binop) => { + count += count_decision_points_expr(&binop.left); + count += count_decision_points_expr(&binop.right); + } + Expr::UnaryOp(unary) => { + count += count_decision_points_expr(&unary.operand); + } + Expr::Compare(cmp) => { + count += count_decision_points_expr(&cmp.left); + for comparator in &cmp.comparators { + count += count_decision_points_expr(comparator); + } + } + _ => {} + } + + count +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source_with_max(source: &str, max: u32) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP007ComplexControlFlow::new(max).check(&parsed, "", source) + } + + #[test] + fn test_simple_function() { + let diagnostics = check_source_with_max( + r#" +def foo(): + return 1 +"#, + 10, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_complex_function() { + let diagnostics = check_source_with_max( + r#" +def foo(x): + if x > 0: + if x > 10: + if x > 100: + return 3 + return 2 + return 1 + elif x < 0: + if x < -10: + return -2 + return -1 + else: + return 0 +"#, + 3, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("cyclomatic complexity")); + } + + #[test] + fn test_boolean_operators() { + let diagnostics = check_source_with_max( + r#" +def foo(a, b, c, d): + if a and b and c and d: + return 1 + return 0 +"#, + 3, + ); + // 1 (base) + 1 (if) + 3 (and operators) = 5 > 3 + assert_eq!(diagnostics.len(), 1); + } + + #[test] + fn test_comprehension_with_if() { + let diagnostics = check_source_with_max( + r#" +def foo(items): + return [x for x in items if x > 0 if x < 100] +"#, + 2, + ); + // 1 (base) + 2 (if clauses in comprehension) = 3 > 2 + assert_eq!(diagnostics.len(), 1); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup008.rs b/exp/guppy-zlup/src/linter/rules/zlup008.rs new file mode 100644 index 000000000..adb98c64a --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup008.rs @@ -0,0 +1,370 @@ +//! ZLUP008: Detect excessive call depth (deeply nested function calls). + +use rustpython_parser::ast::{Expr, Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Maximum allowed call depth (function calls nested within other calls). +const DEFAULT_MAX_CALL_DEPTH: u32 = 4; + +/// Detects excessive call depth that makes code hard to verify. +pub struct ZLUP008CallDepth { + max_depth: u32, +} + +impl ZLUP008CallDepth { + pub fn new(max_depth: u32) -> Self { + Self { max_depth } + } +} + +impl Default for ZLUP008CallDepth { + fn default() -> Self { + Self::new(DEFAULT_MAX_CALL_DEPTH) + } +} + +impl LintRule for ZLUP008CallDepth { + fn id(&self) -> &'static str { + "ZLUP008" + } + + fn name(&self) -> &'static str { + "call-depth" + } + + fn description(&self) -> &'static str { + "Deeply nested function calls make code hard to verify and debug. \ + Keep call chains shallow for better analyzability." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, self.max_depth, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + max_depth: u32, + diagnostics: &mut Vec, +) { + match stmt { + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::For(for_stmt) => { + check_expr(&for_stmt.iter, filename, source, max_depth, 0, diagnostics); + for s in &for_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::While(while_stmt) => { + check_expr( + &while_stmt.test, + filename, + source, + max_depth, + 0, + diagnostics, + ); + for s in &while_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::If(if_stmt) => { + check_expr(&if_stmt.test, filename, source, max_depth, 0, diagnostics); + for s in &if_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::Expr(expr_stmt) => { + check_expr( + &expr_stmt.value, + filename, + source, + max_depth, + 0, + diagnostics, + ); + } + Stmt::Assign(assign) => { + check_expr(&assign.value, filename, source, max_depth, 0, diagnostics); + } + Stmt::AugAssign(aug) => { + check_expr(&aug.value, filename, source, max_depth, 0, diagnostics); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + check_expr(value, filename, source, max_depth, 0, diagnostics); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + check_expr(value, filename, source, max_depth, 0, diagnostics); + } + } + _ => {} + } +} + +fn check_expr( + expr: &Expr, + filename: &str, + source: &str, + max_depth: u32, + current_depth: u32, + diagnostics: &mut Vec, +) { + match expr { + Expr::Call(call) => { + let new_depth = current_depth + 1; + + if new_depth > max_depth { + diagnostics.push( + Diagnostic::warning( + "ZLUP008", + format!( + "Call depth {} exceeds maximum {} - consider breaking into intermediate variables", + new_depth, max_depth + ), + make_location(call.range, filename, source), + ) + .with_suggestion("Extract nested calls into named intermediate values") + .with_source_context(source), + ); + } + + // Check the function being called + check_expr( + &call.func, + filename, + source, + max_depth, + new_depth, + diagnostics, + ); + + // Check arguments + for arg in &call.args { + check_expr(arg, filename, source, max_depth, new_depth, diagnostics); + } + } + + // Recurse into sub-expressions + Expr::BinOp(binop) => { + check_expr( + &binop.left, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + check_expr( + &binop.right, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + } + Expr::UnaryOp(unary) => { + check_expr( + &unary.operand, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + } + Expr::Compare(cmp) => { + check_expr( + &cmp.left, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + for comparator in &cmp.comparators { + check_expr( + comparator, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + } + } + Expr::BoolOp(boolop) => { + for value in &boolop.values { + check_expr( + value, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + } + } + Expr::IfExp(ifexp) => { + check_expr( + &ifexp.test, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + check_expr( + &ifexp.body, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + check_expr( + &ifexp.orelse, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + } + Expr::List(list) => { + for elt in &list.elts { + check_expr(elt, filename, source, max_depth, current_depth, diagnostics); + } + } + Expr::Tuple(tuple) => { + for elt in &tuple.elts { + check_expr(elt, filename, source, max_depth, current_depth, diagnostics); + } + } + Expr::Subscript(sub) => { + check_expr( + &sub.value, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + check_expr( + &sub.slice, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + } + Expr::Attribute(attr) => { + check_expr( + &attr.value, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source_with_max(source: &str, max: u32) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP008CallDepth::new(max).check(&parsed, "", source) + } + + #[test] + fn test_simple_call() { + let diagnostics = check_source_with_max("result = foo()", 4); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_nested_calls() { + let diagnostics = check_source_with_max("result = foo(bar())", 4); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_deeply_nested_calls() { + let diagnostics = check_source_with_max("result = a(b(c(d(e()))))", 3); + assert!(!diagnostics.is_empty()); + assert!(diagnostics[0].message.contains("Call depth")); + } + + #[test] + fn test_call_in_arguments() { + let diagnostics = check_source_with_max("result = foo(bar(baz(qux())))", 2); + assert!(!diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup009.rs b/exp/guppy-zlup/src/linter/rules/zlup009.rs new file mode 100644 index 000000000..aba7a0b50 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup009.rs @@ -0,0 +1,271 @@ +//! ZLUP009: Require assertions in non-trivial functions. + +use rustpython_parser::ast::{Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::LintRule; + +/// Minimum number of statements in a function body to require assertions. +const MIN_STATEMENTS_FOR_ASSERTION: usize = 5; + +/// Detects functions without assertions that should have them. +pub struct ZLUP009AssertionDensity; + +impl LintRule for ZLUP009AssertionDensity { + fn id(&self) -> &'static str { + "ZLUP009" + } + + fn name(&self) -> &'static str { + "assertion-density" + } + + fn description(&self) -> &'static str { + "Non-trivial functions should contain assertions to validate preconditions, \ + postconditions, and invariants. This helps catch errors early." + } + + fn severity(&self) -> Severity { + Severity::Info + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { + match stmt { + Stmt::FunctionDef(func) => { + check_function( + &func.name, + &func.body, + func.range, + filename, + source, + diagnostics, + ); + // Check nested functions + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + check_function( + &func.name, + &func.body, + func.range, + filename, + source, + diagnostics, + ); + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_function( + name: &str, + body: &[Stmt], + range: rustpython_parser::text_size::TextRange, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + // Skip test functions, dunder methods, and trivial functions + if name.starts_with("test_") || name.starts_with("__") { + return; + } + + // Count statements (flattening simple control flow) + let stmt_count = count_statements(body); + + if stmt_count < MIN_STATEMENTS_FOR_ASSERTION { + return; + } + + // Check if function has any assertions + let has_assertion = has_assertion_in_body(body); + + if !has_assertion { + diagnostics.push( + Diagnostic::new( + "ZLUP009", + format!( + "Function '{}' has {} statements but no assertions", + name, stmt_count + ), + Severity::Info, + super::make_location(range, filename, source), + ) + .with_suggestion( + "Add assert statements to validate preconditions, postconditions, or invariants", + ) + .with_source_context(source), + ); + } +} + +fn count_statements(body: &[Stmt]) -> usize { + let mut count = 0; + + for stmt in body { + count += 1; + + // Count statements in nested blocks + match stmt { + Stmt::If(if_stmt) => { + count += count_statements(&if_stmt.body); + count += count_statements(&if_stmt.orelse); + } + Stmt::For(for_stmt) => { + count += count_statements(&for_stmt.body); + count += count_statements(&for_stmt.orelse); + } + Stmt::While(while_stmt) => { + count += count_statements(&while_stmt.body); + count += count_statements(&while_stmt.orelse); + } + Stmt::With(with_stmt) => { + count += count_statements(&with_stmt.body); + } + Stmt::Try(try_stmt) => { + count += count_statements(&try_stmt.body); + count += count_statements(&try_stmt.orelse); + count += count_statements(&try_stmt.finalbody); + } + _ => {} + } + } + + count +} + +fn has_assertion_in_body(body: &[Stmt]) -> bool { + for stmt in body { + if matches!(stmt, Stmt::Assert(_)) { + return true; + } + + // Check nested blocks + let has_nested = match stmt { + Stmt::If(if_stmt) => { + has_assertion_in_body(&if_stmt.body) || has_assertion_in_body(&if_stmt.orelse) + } + Stmt::For(for_stmt) => { + has_assertion_in_body(&for_stmt.body) || has_assertion_in_body(&for_stmt.orelse) + } + Stmt::While(while_stmt) => { + has_assertion_in_body(&while_stmt.body) || has_assertion_in_body(&while_stmt.orelse) + } + Stmt::With(with_stmt) => has_assertion_in_body(&with_stmt.body), + Stmt::Try(try_stmt) => { + has_assertion_in_body(&try_stmt.body) + || has_assertion_in_body(&try_stmt.orelse) + || has_assertion_in_body(&try_stmt.finalbody) + } + Stmt::FunctionDef(_) | Stmt::AsyncFunctionDef(_) => { + // Don't look into nested function definitions + false + } + _ => false, + }; + + if has_nested { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP009AssertionDensity.check(&parsed, "", source) + } + + #[test] + fn test_small_function() { + let diagnostics = check_source( + r#" +def foo(): + x = 1 + return x +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_large_function_without_assertion() { + let diagnostics = check_source( + r#" +def process(data): + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b +"#, + ); + assert!(!diagnostics.is_empty()); + assert!(diagnostics[0].message.contains("no assertions")); + } + + #[test] + fn test_large_function_with_assertion() { + let diagnostics = check_source( + r#" +def process(data): + assert data is not None + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_skip_test_functions() { + let diagnostics = check_source( + r#" +def test_something(): + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup010.rs b/exp/guppy-zlup/src/linter/rules/zlup010.rs new file mode 100644 index 000000000..f9c955f8f --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup010.rs @@ -0,0 +1,242 @@ +//! ZLUP010: Restrict mutable global state. + +use rustpython_parser::ast::{Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; + +/// Names that are allowed as module-level constants (typically UPPER_CASE). +fn is_constant_name(name: &str) -> bool { + // Allow UPPER_CASE names as constants + name.chars() + .all(|c| c.is_uppercase() || c == '_' || c.is_numeric()) + && name + .chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_') +} + +/// Known safe module-level definitions. +fn is_safe_assignment(name: &str) -> bool { + // Type aliases and protocol definitions + name.ends_with("Type") + || name.ends_with("Protocol") + || name.ends_with("T") // Generic type vars + || name == "__all__" + || name == "__version__" + || name == "__author__" +} + +/// Detects mutable global state that can cause issues in concurrent/quantum code. +pub struct ZLUP010GlobalState; + +impl LintRule for ZLUP010GlobalState { + fn id(&self) -> &'static str { + "ZLUP010" + } + + fn name(&self) -> &'static str { + "global-state" + } + + fn description(&self) -> &'static str { + "Mutable global state is prohibited. Use constants (UPPER_CASE names) \ + or pass state explicitly through function parameters." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_module_level_stmt(stmt, filename, source, &mut diagnostics); + } + + diagnostics + } +} + +fn check_module_level_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + match stmt { + // Check for module-level variable assignments + Stmt::Assign(assign) => { + for target in &assign.targets { + if let rustpython_parser::ast::Expr::Name(name) = target { + let var_name = name.id.as_str(); + + // Allow constants and safe assignments + if !is_constant_name(var_name) && !is_safe_assignment(var_name) { + diagnostics.push( + Diagnostic::warning( + "ZLUP010", + format!( + "Module-level variable '{}' creates mutable global state", + var_name + ), + make_location(assign.range, filename, source), + ) + .with_suggestion(format!( + "Use UPPER_CASE for constants (e.g., {}) or pass as function parameter", + var_name.to_uppercase() + )) + .with_source_context(source), + ); + } + } + } + } + + // Check for annotated assignments at module level + Stmt::AnnAssign(ann) => { + if let rustpython_parser::ast::Expr::Name(name) = ann.target.as_ref() { + let var_name = name.id.as_str(); + + if !is_constant_name(var_name) && !is_safe_assignment(var_name) { + diagnostics.push( + Diagnostic::warning( + "ZLUP010", + format!( + "Module-level variable '{}' creates mutable global state", + var_name + ), + make_location(ann.range, filename, source), + ) + .with_suggestion(format!( + "Use UPPER_CASE for constants (e.g., {}) or pass as function parameter", + var_name.to_uppercase() + )) + .with_source_context(source), + ); + } + } + } + + // Check for use of 'global' keyword in functions + Stmt::FunctionDef(func) => { + check_global_in_function(&func.body, filename, source, diagnostics); + } + Stmt::AsyncFunctionDef(func) => { + check_global_in_function(&func.body, filename, source, diagnostics); + } + + // Skip class definitions, imports, etc. + _ => {} + } +} + +fn check_global_in_function( + body: &[Stmt], + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + for stmt in body { + match stmt { + Stmt::Global(global_stmt) => { + for name in &global_stmt.names { + diagnostics.push( + Diagnostic::warning( + "ZLUP010", + format!("Use of 'global {}' creates hidden state dependencies", name), + make_location(global_stmt.range, filename, source), + ) + .with_suggestion("Pass the value as a function parameter instead") + .with_source_context(source), + ); + } + } + Stmt::If(if_stmt) => { + check_global_in_function(&if_stmt.body, filename, source, diagnostics); + check_global_in_function(&if_stmt.orelse, filename, source, diagnostics); + } + Stmt::For(for_stmt) => { + check_global_in_function(&for_stmt.body, filename, source, diagnostics); + } + Stmt::While(while_stmt) => { + check_global_in_function(&while_stmt.body, filename, source, diagnostics); + } + Stmt::With(with_stmt) => { + check_global_in_function(&with_stmt.body, filename, source, diagnostics); + } + Stmt::Try(try_stmt) => { + check_global_in_function(&try_stmt.body, filename, source, diagnostics); + check_global_in_function(&try_stmt.orelse, filename, source, diagnostics); + check_global_in_function(&try_stmt.finalbody, filename, source, diagnostics); + } + Stmt::FunctionDef(func) => { + // Check nested functions + check_global_in_function(&func.body, filename, source, diagnostics); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{Mode, parse}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP010GlobalState.check(&parsed, "", source) + } + + #[test] + fn test_constant_allowed() { + let diagnostics = check_source("MAX_SIZE = 100"); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_lowercase_variable_flagged() { + let diagnostics = check_source("counter = 0"); + assert!(!diagnostics.is_empty()); + assert!(diagnostics[0].message.contains("mutable global state")); + } + + #[test] + fn test_global_keyword_flagged() { + let diagnostics = check_source( + r#" +counter = 0 + +def increment(): + global counter + counter += 1 +"#, + ); + // Should flag both the module-level variable and the global statement + assert!(diagnostics.len() >= 2); + } + + #[test] + fn test_dunder_allowed() { + let diagnostics = check_source("__all__ = ['foo', 'bar']"); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_function_def_ok() { + let diagnostics = check_source( + r#" +def foo(): + x = 1 + return x +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/main.rs b/exp/guppy-zlup/src/main.rs new file mode 100644 index 000000000..e95d30fc0 --- /dev/null +++ b/exp/guppy-zlup/src/main.rs @@ -0,0 +1,948 @@ +//! CLI for guppy-zlup - Guppy linter and Zlup compiler + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::mpsc::channel; +use std::time::Duration; + +use clap::{Parser, Subcommand, ValueEnum}; +use guppy_zlup::{ + CompileError, Config, LintResult, Linter, OutputFormat, Severity, compile_file, ir, lint_source, +}; +use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode}; +use rayon::prelude::*; + +#[derive(Parser)] +#[command(name = "guppy-zlup")] +#[command(author, version, about = "Guppy linter and Zlup compiler")] +#[command( + long_about = "Validate Guppy quantum programs against NASA Power of 10 rules and compile to Zlup" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +/// Output format for lint results. +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +enum CliOutputFormat { + /// Human-readable text output + #[default] + Text, + /// JSON output for machine parsing + Json, + /// SARIF format for GitHub Actions integration + Sarif, +} + +impl From for OutputFormat { + fn from(f: CliOutputFormat) -> Self { + match f { + CliOutputFormat::Text => OutputFormat::Text, + CliOutputFormat::Json => OutputFormat::Json, + CliOutputFormat::Sarif => OutputFormat::Sarif, + } + } +} + +#[derive(Subcommand)] +enum Commands { + /// Validate Guppy files using guppylang (requires Python with guppylang installed) + Validate { + /// Files to validate + #[arg(required = true)] + files: Vec, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Check files for lint violations + Check { + /// Files or directories to check + #[arg(required = true)] + files: Vec, + + /// Treat warnings as errors + #[arg(short = 'W', long)] + warnings_as_errors: bool, + + /// Path to config file (pyproject.toml) + #[arg(long)] + config: Option, + + /// Disable specific rules (can be used multiple times) + #[arg(long = "disable", short = 'D')] + disabled_rules: Vec, + + /// Maximum complexity for ZLUP007 + #[arg(long)] + max_complexity: Option, + + /// Output format + #[arg(long, short = 'f', value_enum, default_value = "text")] + format: CliOutputFormat, + + /// Watch for file changes and re-lint + #[arg(long, short = 'w')] + watch: bool, + }, + + /// Emit validated IR as JSON + Emit { + /// File to emit + file: PathBuf, + + /// Output file (default: ir.json) + #[arg(short, long, default_value = "ir.json")] + output: PathBuf, + + /// Skip lint check + #[arg(long)] + skip_lint: bool, + + /// Print to stdout instead of writing to file + #[arg(long)] + stdout: bool, + }, + + /// Compile Guppy source or IR to Zlup + Compile { + /// Input file (.py for source, .json for IR) + input: PathBuf, + + /// Output file (default: .zlp) + #[arg(short, long)] + output: Option, + + /// Print to stdout instead of writing to file + #[arg(long)] + stdout: bool, + + /// Input is IR JSON (skip linting) + #[arg(long)] + ir: bool, + + /// Validate with guppylang before compiling (requires Python) + #[arg(long)] + validate: bool, + + /// Run parallelism analysis on generated Zlup + #[arg(long)] + analyze: bool, + }, + + /// Analyze Guppy source for parallelism opportunities (compiles to Zlup first) + Analyze { + /// Input file (.py for source, .json for IR) + input: PathBuf, + + /// Input is IR JSON (skip linting) + #[arg(long)] + ir: bool, + + /// Output format (text or json) + #[arg(short, long, value_enum, default_value = "text")] + format: AnalyzeFormat, + + /// Show detailed dependency graph + #[arg(long, short)] + verbose: bool, + }, +} + +/// Output format for analysis results. +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +enum AnalyzeFormat { + /// Human-readable text output + #[default] + Text, + /// JSON output for tooling integration + Json, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + match cli.command { + Commands::Validate { files, json } => cmd_validate(&files, json), + + Commands::Check { + files, + warnings_as_errors, + config, + disabled_rules, + max_complexity, + format, + watch, + } => { + if watch { + cmd_watch( + &files, + warnings_as_errors, + config.as_deref(), + &disabled_rules, + max_complexity, + format.into(), + ) + } else { + cmd_check( + &files, + warnings_as_errors, + config.as_deref(), + &disabled_rules, + max_complexity, + format.into(), + ) + } + } + + Commands::Emit { + file, + output, + skip_lint, + stdout, + } => cmd_emit(&file, &output, skip_lint, stdout), + + Commands::Compile { + input, + output, + stdout, + ir, + validate, + analyze, + } => { + if ir { + cmd_compile_ir(&input, output.as_deref(), stdout, analyze) + } else { + cmd_compile_source(&input, output.as_deref(), stdout, validate, analyze) + } + } + + Commands::Analyze { + input, + ir, + format, + verbose, + } => cmd_analyze(&input, ir, format, verbose), + } +} + +/// Collect all Python files from paths (files or directories). +fn collect_python_files(paths: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + + for path in paths { + if path.is_file() { + files.push(path.clone()); + } else if path.is_dir() { + // Recursively find all .py files + if let Ok(entries) = walkdir(path) { + files.extend(entries); + } + } + } + + files +} + +/// Walk directory recursively to find Python files. +fn walkdir(dir: &Path) -> std::io::Result> { + let mut files = Vec::new(); + + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Skip hidden directories and common non-source dirs + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !name.starts_with('.') + && name != "__pycache__" + && name != "node_modules" + && name != "venv" + { + files.extend(walkdir(&path)?); + } + } else if path.extension().is_some_and(|ext| ext == "py") { + files.push(path); + } + } + + Ok(files) +} + +fn cmd_check( + paths: &[PathBuf], + warnings_as_errors: bool, + config_path: Option<&Path>, + disabled_rules: &[String], + max_complexity: Option, + format: OutputFormat, +) -> ExitCode { + // Collect all Python files + let files = collect_python_files(paths); + + if files.is_empty() { + eprintln!("No Python files found."); + return ExitCode::from(1); + } + + // Load base config + let mut base_config = if let Some(path) = config_path { + match Config::from_pyproject(path) { + Ok(c) => c, + Err(e) => { + eprintln!("Error loading config from {}: {}", path.display(), e); + return ExitCode::from(1); + } + } + } else if let Some(first_file) = files.first() { + if let Some(pyproject) = Config::find_pyproject(first_file) { + match Config::from_pyproject(&pyproject) { + Ok(c) => c, + Err(e) => { + eprintln!("Warning: Error loading {}: {}", pyproject.display(), e); + Config::default() + } + } + } else { + Config::default() + } + } else { + Config::default() + }; + + // Apply CLI overrides + if warnings_as_errors { + base_config.treat_warnings_as_errors = true; + } + + for rule in disabled_rules { + base_config.disable_rule(rule); + } + + if let Some(complexity) = max_complexity { + base_config.max_complexity = complexity; + } + + // Lint all files in parallel + let results: Vec<(PathBuf, Result)> = files + .par_iter() + .map(|file| { + let result = std::fs::read_to_string(file).map(|source| { + let linter = Linter::new(base_config.clone()); + linter.lint_source(&source, file.to_str().unwrap_or("")) + }); + (file.clone(), result) + }) + .collect(); + + // Merge all results + let mut combined = LintResult::default(); + let mut io_errors = Vec::new(); + + for (file, result) in results { + match result { + Ok(lint_result) => combined.merge(lint_result), + Err(e) => io_errors.push((file, e)), + } + } + + // Report IO errors + for (file, e) in &io_errors { + eprintln!("Error reading {}: {}", file.display(), e); + } + + // Output results in the requested format + let output = combined.format(format); + println!("{}", output); + + // Summary for text format + if format == OutputFormat::Text && !combined.diagnostics.is_empty() { + let error_count = combined + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error)) + .count(); + let warning_count = combined + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + println!( + "\nFound {} error(s) and {} warning(s) in {} file(s).", + error_count, + warning_count, + files.len() + ); + } + + if !io_errors.is_empty() || !combined.is_ok(base_config.treat_warnings_as_errors) { + return ExitCode::from(1); + } + + if format == OutputFormat::Text { + println!("All checks passed!"); + } + ExitCode::SUCCESS +} + +fn cmd_watch( + paths: &[PathBuf], + warnings_as_errors: bool, + config_path: Option<&Path>, + disabled_rules: &[String], + max_complexity: Option, + format: OutputFormat, +) -> ExitCode { + println!("Watching for changes... (press Ctrl+C to stop)"); + + // Run initial check + let _ = cmd_check( + paths, + warnings_as_errors, + config_path, + disabled_rules, + max_complexity, + format, + ); + println!("\n---\n"); + + // Set up file watcher + let (tx, rx) = channel(); + + let mut debouncer = match new_debouncer(Duration::from_millis(500), tx) { + Ok(d) => d, + Err(e) => { + eprintln!("Error creating file watcher: {}", e); + return ExitCode::from(1); + } + }; + + // Watch all paths + for path in paths { + let watch_path = if path.is_file() { + path.parent().unwrap_or(path) + } else { + path.as_path() + }; + + if let Err(e) = debouncer + .watcher() + .watch(watch_path, RecursiveMode::Recursive) + { + eprintln!("Error watching {}: {}", watch_path.display(), e); + } + } + + // Convert config_path to owned PathBuf for the loop + let config_path_owned = config_path.map(|p| p.to_path_buf()); + let disabled_rules_owned: Vec = disabled_rules.to_vec(); + + // Watch loop + loop { + match rx.recv() { + Ok(Ok(events)) => { + // Check if any Python files changed + let has_py_changes = events + .iter() + .any(|e| e.path.extension().is_some_and(|ext| ext == "py")); + + if has_py_changes { + // Clear screen (optional, works on most terminals) + print!("\x1B[2J\x1B[1;1H"); + println!("File changed, re-checking...\n"); + + let _ = cmd_check( + paths, + warnings_as_errors, + config_path_owned.as_deref(), + &disabled_rules_owned, + max_complexity, + format, + ); + println!("\n---\nWatching for changes... (press Ctrl+C to stop)"); + } + } + Ok(Err(errors)) => { + eprintln!("Watch error: {:?}", errors); + } + Err(e) => { + eprintln!("Channel error: {}", e); + return ExitCode::from(1); + } + } + } +} + +fn cmd_emit(file: &PathBuf, output: &PathBuf, skip_lint: bool, to_stdout: bool) -> ExitCode { + let source = match std::fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file: {}", e); + return ExitCode::from(1); + } + }; + + if !skip_lint { + let config = Config::default(); + let linter = Linter::new(config); + let result = linter.lint_source(&source, file.to_str().unwrap_or("")); + + if result.has_errors { + println!("{}", result); + println!("\nCannot emit IR due to lint errors."); + return ExitCode::from(1); + } + + if result.has_warnings { + println!("{}", result); + println!(); + } + } + + // Emit IR + match ir::emit_ir(&source, file.to_str()) { + Ok(ir_data) => { + let json = serde_json::to_string_pretty(&ir_data).unwrap(); + + if to_stdout { + println!("{}", json); + return ExitCode::SUCCESS; + } + + if let Err(e) = std::fs::write(output, json) { + eprintln!("Error writing IR: {}", e); + return ExitCode::from(1); + } + println!("Wrote IR to {}", output.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("Error emitting IR: {}", e); + ExitCode::from(1) + } + } +} + +fn cmd_compile_ir(input: &Path, output: Option<&Path>, to_stdout: bool, analyze: bool) -> ExitCode { + let result = match compile_file(input.to_str().unwrap_or("")) { + Ok(zlup) => zlup, + Err(e) => { + eprintln!("Error: {}", format_error(&e)); + return ExitCode::from(1); + } + }; + + if to_stdout { + println!("{}", result); + if analyze { + eprintln!(); + run_analysis(&result, AnalyzeFormat::Text, false); + } + return ExitCode::SUCCESS; + } + + let output_path = output + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| input.with_extension("zlp")); + + if let Err(e) = std::fs::write(&output_path, &result) { + eprintln!("Error writing output: {}", e); + return ExitCode::from(1); + } + + println!("Compiled {} -> {}", input.display(), output_path.display()); + + if analyze { + println!(); + run_analysis(&result, AnalyzeFormat::Text, false); + } + + ExitCode::SUCCESS +} + +fn cmd_compile_source( + input: &PathBuf, + output: Option<&Path>, + to_stdout: bool, + validate: bool, + analyze: bool, +) -> ExitCode { + // Run guppylang validation if requested + if validate { + let exit = cmd_validate(std::slice::from_ref(input), false); + if exit != ExitCode::SUCCESS { + eprintln!("\nCannot compile due to validation errors."); + return exit; + } + } + + let source = match std::fs::read_to_string(input) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file: {}", e); + return ExitCode::from(1); + } + }; + + // Run linter + let result = lint_source(&source, input.to_str()); + if result.has_errors { + println!("{}", result); + let error_count = result + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error)) + .count(); + println!("\nCannot compile due to {} lint error(s).", error_count); + return ExitCode::from(1); + } + + if result.has_warnings { + println!("{}", result); + println!(); + } + + // Emit IR + let ir_data = match ir::emit_ir(&source, input.to_str()) { + Ok(ir) => ir, + Err(e) => { + eprintln!("Error emitting IR: {}", e); + return ExitCode::from(1); + } + }; + + // Compile IR to Zlup + let ir_json = serde_json::to_string(&ir_data).unwrap(); + let zlup_source = match guppy_zlup::compile(&ir_json) { + Ok(src) => src, + Err(e) => { + eprintln!("Error compiling to Zlup: {}", e); + return ExitCode::from(1); + } + }; + + if to_stdout { + println!("{}", zlup_source); + if analyze { + eprintln!(); + run_analysis(&zlup_source, AnalyzeFormat::Text, false); + } + return ExitCode::SUCCESS; + } + + let output_path = output + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| input.with_extension("zlp")); + + if let Err(e) = std::fs::write(&output_path, &zlup_source) { + eprintln!("Error writing output: {}", e); + return ExitCode::from(1); + } + + println!("Compiled {} -> {}", input.display(), output_path.display()); + + if analyze { + println!(); + run_analysis(&zlup_source, AnalyzeFormat::Text, false); + } + + ExitCode::SUCCESS +} + +fn format_error(e: &CompileError) -> String { + match e { + CompileError::Io(io) => format!("IO error: {}", io), + CompileError::Parse(parse) => format!("Parse error: {}", parse), + CompileError::Transform(transform) => format!("Transform error: {}", transform), + } +} + +fn cmd_analyze(input: &PathBuf, is_ir: bool, format: AnalyzeFormat, verbose: bool) -> ExitCode { + // Get Zlup source - either compile from Guppy/IR or read directly + let zlup_source = if is_ir { + match compile_file(input.to_str().unwrap_or("")) { + Ok(zlup) => zlup, + Err(e) => { + eprintln!("Error compiling IR: {}", format_error(&e)); + return ExitCode::from(1); + } + } + } else { + let source = match std::fs::read_to_string(input) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file: {}", e); + return ExitCode::from(1); + } + }; + + // Run linter first + let result = lint_source(&source, input.to_str()); + if result.has_errors { + println!("{}", result); + let error_count = result + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error)) + .count(); + println!("\nCannot analyze due to {} lint error(s).", error_count); + return ExitCode::from(1); + } + + // Emit IR and compile to Zlup + let ir_data = match ir::emit_ir(&source, input.to_str()) { + Ok(ir) => ir, + Err(e) => { + eprintln!("Error emitting IR: {}", e); + return ExitCode::from(1); + } + }; + + let ir_json = serde_json::to_string(&ir_data).unwrap(); + match guppy_zlup::compile(&ir_json) { + Ok(src) => src, + Err(e) => { + eprintln!("Error compiling to Zlup: {}", e); + return ExitCode::from(1); + } + } + }; + + run_analysis(&zlup_source, format, verbose); + ExitCode::SUCCESS +} + +fn run_analysis(zlup_source: &str, format: AnalyzeFormat, verbose: bool) { + use zlup::analysis::{ + AllocatorAnalysis, DependencyGraph, OperationTagger, analyze_parallelism, + }; + + // Parse the Zlup source + let program = match zlup::parse(zlup_source) { + Ok(p) => p, + Err(e) => { + eprintln!("Error parsing generated Zlup: {}", e); + return; + } + }; + + // Run analysis passes + let allocator_analysis = AllocatorAnalysis::analyze(&program); + let tagger = OperationTagger::tag(&program); + let dep_graph = DependencyGraph::build(tagger.operations); + let summaries = analyze_parallelism(&program); + + match format { + AnalyzeFormat::Text => { + println!("=== Parallelism Analysis ===\n"); + + // Allocator summary + println!("Allocators:"); + if allocator_analysis.allocators.is_empty() { + println!(" (none)"); + } else { + for (name, info) in &allocator_analysis.allocators { + let size_str = info + .size + .map(|s| format!("[{}]", s)) + .unwrap_or_else(|| "[?]".to_string()); + println!( + " {} {}qubit (scope depth: {}, line: {})", + name, size_str, info.scope_depth, info.defined_at_line + ); + } + } + println!(); + + // Function summaries + println!("Function Analysis:"); + for summary in &summaries { + println!(" {}:", summary.function_name); + println!(" Total operations: {}", summary.total_ops); + println!(" Quantum operations: {}", summary.quantum_ops); + println!(" Classical operations: {}", summary.classical_ops); + println!(" Parallel layers: {}", summary.num_layers); + println!(" Max parallelism: {} ops/layer", summary.max_parallelism); + println!(); + } + + // Detailed output if verbose + if verbose { + println!("=== Dependency Graph ===\n"); + dep_graph.debug_print(); + } + } + AnalyzeFormat::Json => { + use serde_json::json; + + let allocators: Vec<_> = allocator_analysis + .allocators + .iter() + .map(|(name, info)| { + json!({ + "name": name, + "size": info.size, + "scope_depth": info.scope_depth, + "defined_at_line": info.defined_at_line, + }) + }) + .collect(); + + let functions: Vec<_> = summaries + .iter() + .map(|s| { + json!({ + "name": s.function_name, + "total_ops": s.total_ops, + "quantum_ops": s.quantum_ops, + "classical_ops": s.classical_ops, + "num_layers": s.num_layers, + "max_parallelism": s.max_parallelism, + }) + }) + .collect(); + + let layers = dep_graph.parallel_layers(); + let layer_details: Vec<_> = layers + .iter() + .enumerate() + .map(|(i, ops)| { + let op_details: Vec<_> = ops + .iter() + .map(|&id| { + let op = &dep_graph.operations[id]; + json!({ + "id": op.id, + "description": op.description, + "line": op.line, + "is_quantum": op.touches_qubits(), + }) + }) + .collect(); + json!({ + "layer": i, + "operations": op_details, + }) + }) + .collect(); + + let output = json!({ + "allocators": allocators, + "functions": functions, + "parallel_layers": layer_details, + "total_operations": dep_graph.operations.len(), + "total_dependencies": dep_graph.edges.len(), + }); + + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } + } +} + +fn cmd_validate(files: &[PathBuf], json_output: bool) -> ExitCode { + // Find the validation script relative to the executable or in known locations + let script_locations = [ + // Relative to executable + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.join("scripts/validate_guppy.py"))), + // In the source tree + Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/validate_guppy.py")), + ]; + + let script_path = script_locations + .iter() + .filter_map(|p| p.as_ref()) + .find(|p| p.exists()); + + let script_path = match script_path { + Some(p) => p.clone(), + None => { + eprintln!("Error: Could not find validate_guppy.py script"); + eprintln!("Make sure guppy-zlup is properly installed"); + return ExitCode::from(1); + } + }; + + let python_files = collect_python_files(files); + if python_files.is_empty() { + eprintln!("No Python files found."); + return ExitCode::from(1); + } + + let mut all_valid = true; + let mut results: Vec = Vec::new(); + + for file in &python_files { + // Try uv first, then fall back to python3 + let output = std::process::Command::new("uv") + .args(["run", "python"]) + .arg(&script_path) + .arg(file) + .arg(if json_output { "--json" } else { "" }) + .output() + .or_else(|_| { + std::process::Command::new("python3") + .arg(&script_path) + .arg(file) + .arg(if json_output { "--json" } else { "" }) + .output() + }); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + if json_output { + if let Ok(json) = serde_json::from_str::(&stdout) { + results.push(json); + } + } else { + if !stdout.is_empty() { + print!("{}", stdout); + } + if !stderr.is_empty() { + eprint!("{}", stderr); + } + } + + if !out.status.success() { + all_valid = false; + } + } + Err(e) => { + eprintln!("Error running validation for {}: {}", file.display(), e); + eprintln!( + "Make sure Python with guppylang is available (uv run python or python3)" + ); + all_valid = false; + } + } + } + + if json_output { + let combined = serde_json::json!({ + "files": results, + "all_valid": all_valid, + }); + println!("{}", serde_json::to_string_pretty(&combined).unwrap()); + } else if all_valid { + println!( + "\nAll {} file(s) validated successfully!", + python_files.len() + ); + } else { + println!("\nValidation failed for some files."); + } + + if all_valid { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } +} diff --git a/exp/guppy-zlup/tests/e2e_tests.rs b/exp/guppy-zlup/tests/e2e_tests.rs new file mode 100644 index 000000000..55c9d37a8 --- /dev/null +++ b/exp/guppy-zlup/tests/e2e_tests.rs @@ -0,0 +1,739 @@ +//! End-to-end tests for Guppy to Zlup compilation. +//! +//! These tests verify the full pipeline from Guppy source to valid Zlup output. + +use guppy_zlup::{lint_and_compile, lint_source}; + +/// Test helper to compile Guppy source and verify Zlup output. +fn compile_and_check(source: &str, expected_patterns: &[&str]) { + let result = lint_and_compile(source, None); + match result { + Ok(zlup) => { + for pattern in expected_patterns { + assert!( + zlup.contains(pattern), + "Expected pattern '{}' not found in output:\n{}", + pattern, + zlup + ); + } + } + Err(e) => panic!("Compilation failed: {:?}", e), + } +} + +/// Test helper to verify linting catches errors. +fn lint_should_error(source: &str, expected_rule: &str) { + let result = lint_source(source, None); + assert!( + result.has_errors, + "Expected lint errors but got none for rule {}", + expected_rule + ); + assert!( + result + .diagnostics + .iter() + .any(|d| d.rule_id == expected_rule), + "Expected rule {} but got: {:?}", + expected_rule, + result + .diagnostics + .iter() + .map(|d| &d.rule_id) + .collect::>() + ); +} + +// ============================================================================= +// Basic Function Tests +// ============================================================================= + +#[test] +fn test_e2e_simple_function() { + let source = r#" +def add(a: int, b: int) -> int: + return a + b +"#; + compile_and_check( + source, + &["fn add(a: i64, b: i64)", "-> i64", "return a + b"], + ); +} + +#[test] +fn test_e2e_void_function() { + let source = r#" +def noop() -> None: + pass +"#; + compile_and_check(source, &["fn noop()", "-> unit", "return;"]); +} + +#[test] +fn test_e2e_variable_binding() { + let source = r#" +def compute() -> int: + x: int = 10 + y: int = 20 + return x + y +"#; + compile_and_check( + source, + &["mut x: i64 = 10", "mut y: i64 = 20", "return x + y"], + ); +} + +// ============================================================================= +// Control Flow Tests +// ============================================================================= + +#[test] +fn test_e2e_if_else() { + let source = r#" +def abs_val(x: int) -> int: + if x < 0: + return -x + else: + return x +"#; + compile_and_check(source, &["if (x < 0)", "return -x", "else", "return x"]); +} + +#[test] +fn test_e2e_for_loop() { + let source = r#" +def sum_range() -> int: + total: int = 0 + for i in range(10): + total = total + i + return total +"#; + // total = total + i (assignment, not binding) since total is already declared + compile_and_check(source, &["for i in 0..10", "total = total + i"]); +} + +#[test] +fn test_e2e_nested_for() { + let source = r#" +def nested() -> int: + sum: int = 0 + for i in range(3): + for j in range(3): + sum = sum + 1 + return sum +"#; + compile_and_check(source, &["for i in 0..3", "for j in 0..3"]); +} + +// ============================================================================= +// Quantum Operation Tests +// ============================================================================= + +#[test] +fn test_e2e_single_qubit_gate() { + let source = r#" +def apply_h() -> None: + q = qubit[1] + h(q[0]) +"#; + compile_and_check(source, &["qalloc(1)", "h q[0]"]); +} + +#[test] +fn test_e2e_two_qubit_gate() { + let source = r#" +def apply_cx() -> None: + q = qubit[2] + cx(q[0], q[1]) +"#; + compile_and_check(source, &["qalloc(2)", "cx (q[0], q[1])"]); +} + +#[test] +fn test_e2e_multiple_gates() { + let source = r#" +def bell_prep() -> None: + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) +"#; + compile_and_check(source, &["h q[0]", "cx (q[0], q[1])"]); +} + +#[test] +fn test_e2e_measure_single() { + let source = r#" +def measure_one() -> None: + q = qubit[1] + h(q[0]) + m = measure(q[0]) +"#; + compile_and_check(source, &["h q[0]", "mz(u1) q[0]"]); +} + +#[test] +fn test_e2e_measure_register() { + let source = r#" +def measure_all() -> None: + q = qubit[4] + for i in range(4): + h(q[i]) + m = measure(q) +"#; + compile_and_check(source, &["qalloc(4)", "for i in 0..4", "mz([4]u1) q"]); +} + +// ============================================================================= +// Lint Rule Tests +// ============================================================================= + +#[test] +fn test_e2e_lint_unbounded_loop() { + let source = r#" +def bad_loop() -> None: + while True: + pass +"#; + lint_should_error(source, "ZLUP001"); +} + +#[test] +fn test_e2e_lint_recursion() { + let source = r#" +def factorial(n: int) -> int: + if n <= 1: + return 1 + return n * factorial(n - 1) +"#; + lint_should_error(source, "ZLUP002"); +} + +#[test] +fn test_e2e_lint_dynamic_alloc_in_loop() { + let source = r#" +def bad_alloc() -> None: + for i in range(10): + items = [] + items.append(i) +"#; + lint_should_error(source, "ZLUP003"); +} + +#[test] +fn test_e2e_lint_eval() { + let source = r#" +def bad_eval() -> None: + eval("print('hello')") +"#; + lint_should_error(source, "ZLUP004"); +} + +// ============================================================================= +// Type Tests +// ============================================================================= + +#[test] +fn test_e2e_bool_type() { + let source = r#" +def check(x: int) -> bool: + return x > 0 +"#; + compile_and_check(source, &["-> bool", "return x > 0"]); +} + +#[test] +fn test_e2e_float_type() { + let source = r#" +def half(x: float) -> float: + return x / 2.0 +"#; + compile_and_check(source, &["x: f64", "-> f64"]); +} + +#[test] +fn test_e2e_parameterized_gate() { + // Note: Guppy convention is gate(qubit, angle) + let source = r#" +def rotate() -> None: + q = qubit[1] + rz(q[0], 3.14) +"#; + compile_and_check(source, &["qalloc(1)", "rz(3.14) q[0]"]); +} + +#[test] +fn test_e2e_three_qubit_gate() { + let source = r#" +def toffoli_test() -> None: + q = qubit[3] + ccx(q[0], q[1], q[2]) +"#; + compile_and_check(source, &["qalloc(3)", "ccx"]); +} + +#[test] +fn test_e2e_multiple_different_gates() { + let source = r#" +def gate_sequence() -> None: + q = qubit[2] + h(q[0]) + x(q[1]) + cx(q[0], q[1]) + z(q[0]) +"#; + compile_and_check(source, &["h q[0]", "x q[1]", "cx (q[0], q[1])", "z q[0]"]); +} + +// ============================================================================= +// Expression Tests +// ============================================================================= + +#[test] +fn test_e2e_arithmetic() { + let source = r#" +def math(a: int, b: int) -> int: + return (a + b) * (a - b) +"#; + compile_and_check(source, &["a + b", "a - b"]); +} + +#[test] +fn test_e2e_comparison() { + let source = r#" +def compare(a: int, b: int) -> bool: + return a <= b +"#; + compile_and_check(source, &["a <= b"]); +} + +#[test] +fn test_e2e_unary() { + let source = r#" +def negate(x: int) -> int: + return -x +"#; + compile_and_check(source, &["return -x"]); +} + +#[test] +fn test_e2e_boolean_and() { + let source = r#" +def both(a: bool, b: bool) -> bool: + return a and b +"#; + compile_and_check(source, &["a and b"]); +} + +#[test] +fn test_e2e_boolean_or() { + let source = r#" +def either(a: bool, b: bool) -> bool: + return a or b +"#; + compile_and_check(source, &["a or b"]); +} + +#[test] +fn test_e2e_chained_boolean() { + let source = r#" +def all_three(a: bool, b: bool, c: bool) -> bool: + return a and b and c +"#; + // Chained: (a and b) and c + compile_and_check(source, &["and"]); +} + +// ============================================================================= +// Control Flow Tests (while, break, continue) +// ============================================================================= + +#[test] +fn test_e2e_while_loop_with_break() { + // While loops are transformed to bounded for loops with break condition + let source = r#" +def count_up(limit: int) -> int: + i: int = 0 + while i < limit: + i = i + 1 + return i +"#; + // Should transform to for loop with break + compile_and_check(source, &["for", "break"]); +} + +#[test] +fn test_e2e_break_statement() { + let source = r#" +def find_first() -> int: + result: int = 0 + for i in range(10): + if i > 5: + result = i + break + return result +"#; + compile_and_check(source, &["break"]); +} + +#[test] +fn test_e2e_continue_statement() { + let source = r#" +def skip_evens() -> int: + total: int = 0 + for i in range(10): + if i % 2 == 0: + continue + total = total + i + return total +"#; + compile_and_check(source, &["continue"]); +} + +// ============================================================================= +// Edge Case Tests +// ============================================================================= + +#[test] +fn test_e2e_augmented_assignment_add() { + let source = r#" +def increment() -> int: + x: int = 5 + x += 3 + return x +"#; + compile_and_check(source, &["mut x: i64 = 5", "x = x + 3"]); +} + +#[test] +fn test_e2e_augmented_assignment_sub() { + let source = r#" +def decrement() -> int: + x: int = 10 + x -= 4 + return x +"#; + compile_and_check(source, &["x = x - 4"]); +} + +#[test] +fn test_e2e_augmented_assignment_mul() { + let source = r#" +def double() -> int: + x: int = 5 + x *= 2 + return x +"#; + compile_and_check(source, &["x = x * 2"]); +} + +#[test] +fn test_e2e_nested_for_loops() { + let source = r#" +def matrix_sum() -> int: + total: int = 0 + for i in range(3): + for j in range(4): + total += 1 + return total +"#; + compile_and_check( + source, + &["for i in 0..3", "for j in 0..4", "total = total + 1"], + ); +} + +#[test] +fn test_e2e_deeply_nested_loops() { + let source = r#" +def cube_sum() -> int: + total: int = 0 + for i in range(2): + for j in range(2): + for k in range(2): + total += 1 + return total +"#; + compile_and_check(source, &["for i in 0..2", "for j in 0..2", "for k in 0..2"]); +} + +#[test] +fn test_e2e_nested_loop_with_outer_var() { + let source = r#" +def product_sum() -> int: + total: int = 0 + for i in range(5): + for j in range(3): + total = total + i * j + return total +"#; + compile_and_check(source, &["total = total + i * j"]); +} + +#[test] +fn test_e2e_break_in_nested_loop() { + let source = r#" +def find_product() -> int: + result: int = 0 + for i in range(5): + for j in range(5): + if i * j > 6: + result = i * j + break + return result +"#; + compile_and_check(source, &["if (i * j > 6)", "break"]); +} + +#[test] +fn test_e2e_multiple_variables() { + let source = r#" +def swap_like() -> int: + a: int = 1 + b: int = 2 + c: int = 3 + a = b + b = c + c = a + return a + b + c +"#; + compile_and_check( + source, + &["mut a: i64 = 1", "mut b: i64 = 2", "mut c: i64 = 3"], + ); +} + +#[test] +fn test_e2e_complex_arithmetic() { + let source = r#" +def calculate(x: int, y: int) -> int: + return (x + y) * (x - y) + x * y +"#; + compile_and_check(source, &["x + y", "x - y", "x * y"]); +} + +#[test] +fn test_e2e_chained_comparison() { + let source = r#" +def in_range(x: int, lo: int, hi: int) -> bool: + return x >= lo and x <= hi +"#; + compile_and_check(source, &["x >= lo", "x <= hi", "and"]); +} + +#[test] +fn test_e2e_boolean_operators() { + let source = r#" +def complex_bool(a: bool, b: bool, c: bool) -> bool: + return (a and b) or (a and c) or (b and c) +"#; + compile_and_check(source, &["and", "or"]); +} + +#[test] +fn test_e2e_if_elif_else() { + let source = r#" +def classify(x: int) -> int: + if x < 0: + return -1 + elif x == 0: + return 0 + else: + return 1 +"#; + compile_and_check(source, &["if (x < 0)", "return -1", "else", "return 1"]); +} + +#[test] +fn test_e2e_nested_if() { + let source = r#" +def nested_cond(a: int, b: int) -> int: + if a > 0: + if b > 0: + return 1 + else: + return 2 + else: + return 3 +"#; + compile_and_check( + source, + &[ + "if (a > 0)", + "if (b > 0)", + "return 1", + "return 2", + "return 3", + ], + ); +} + +#[test] +fn test_e2e_while_with_multiple_conditions() { + let source = r#" +def bounded_count(limit: int) -> int: + i: int = 0 + count: int = 0 + while i < limit and count < 50: + count += 1 + i += 1 + return count +"#; + compile_and_check(source, &["for", "break", "and"]); +} + +#[test] +fn test_e2e_multiple_quantum_registers() { + let source = r#" +def multi_reg() -> None: + a = qubit[2] + b = qubit[3] + h(a[0]) + h(b[0]) + cx(a[0], b[0]) +"#; + compile_and_check( + source, + &[ + "qalloc(2)", + "qalloc(3)", + "h a[0]", + "h b[0]", + "cx (a[0], b[0])", + ], + ); +} + +#[test] +fn test_e2e_loop_with_quantum_ops() { + let source = r#" +def apply_h_to_all() -> None: + q = qubit[4] + for i in range(4): + h(q[i]) +"#; + compile_and_check(source, &["qalloc(4)", "for i in 0..4", "h q[i]"]); +} + +#[test] +fn test_e2e_modulo_operator() { + let source = r#" +def is_even(x: int) -> bool: + return x % 2 == 0 +"#; + compile_and_check(source, &["x % 2 == 0"]); +} + +#[test] +fn test_e2e_integer_division() { + let source = r#" +def half(x: int) -> int: + return x // 2 +"#; + compile_and_check(source, &["x / 2"]); +} + +// ============================================================================= +// Analysis Integration Tests +// ============================================================================= + +#[test] +fn test_e2e_analysis_after_compile() { + use zlup::analysis::{AllocatorAnalysis, analyze_parallelism}; + + let source = r#" +def bell() -> None: + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) +"#; + + // Compile Guppy to Zlup + let zlup_source = lint_and_compile(source, None).expect("compile failed"); + + // Parse Zlup and run analysis + let program = zlup::parse(&zlup_source).expect("parse failed"); + + let allocator_analysis = AllocatorAnalysis::analyze(&program); + assert!( + allocator_analysis.allocators.contains_key("q"), + "Should detect allocator q" + ); + + let summaries = analyze_parallelism(&program); + assert!(!summaries.is_empty(), "Should have function summaries"); + + let bell_summary = summaries.iter().find(|s| s.function_name == "bell"); + assert!(bell_summary.is_some(), "Should have bell function summary"); + assert!( + bell_summary.unwrap().quantum_ops >= 2, + "Should have at least 2 quantum ops" + ); +} + +#[test] +fn test_e2e_analysis_disjoint_registers() { + use zlup::analysis::analyze_parallelism; + + let source = r#" +def parallel_prep() -> None: + q1 = qubit[2] + q2 = qubit[2] + h(q1[0]) + h(q2[0]) +"#; + + let zlup_source = lint_and_compile(source, None).expect("compile failed"); + let program = zlup::parse(&zlup_source).expect("parse failed"); + + let summaries = analyze_parallelism(&program); + let summary = &summaries[0]; + + // Two independent H gates on different allocators should enable parallelism + assert!( + summary.max_parallelism >= 2, + "Disjoint allocators should enable parallelism" + ); +} + +#[test] +fn test_e2e_analysis_classical_quantum_independence() { + use zlup::analysis::analyze_parallelism; + + let source = r#" +def mixed_ops() -> None: + q = qubit[2] + x = 1 + 2 + h(q[0]) + y = x * 3 +"#; + + let zlup_source = lint_and_compile(source, None).expect("compile failed"); + let program = zlup::parse(&zlup_source).expect("parse failed"); + + let summaries = analyze_parallelism(&program); + let summary = &summaries[0]; + + assert!(summary.classical_ops > 0, "Should have classical ops"); + assert!(summary.quantum_ops > 0, "Should have quantum ops"); +} + +#[test] +fn test_e2e_lint_error_blocks_analysis() { + // Source with lint error (unbounded loop) + let source = r#" +def bad_loop() -> None: + while True: + pass +"#; + + let result = lint_source(source, None); + assert!(result.has_errors, "Should detect lint error"); + + // lint_and_compile should fail + let compile_result = lint_and_compile(source, None); + assert!( + compile_result.is_err(), + "Should fail to compile with lint errors" + ); +} diff --git a/exp/guppy-zlup/tests/transform_tests.rs b/exp/guppy-zlup/tests/transform_tests.rs new file mode 100644 index 000000000..6cebf201d --- /dev/null +++ b/exp/guppy-zlup/tests/transform_tests.rs @@ -0,0 +1,391 @@ +//! Integration tests for Guppy IR to Zlup transformation. + +use guppy_zlup::{CompileError, compile}; + +#[test] +fn test_compile_empty_program() { + let ir = r#"{ + "version": "0.1.0", + "functions": [] + }"#; + + let result = compile(ir); + assert!(result.is_ok()); +} + +#[test] +fn test_compile_simple_function() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "main", + "params": [], + "body": [] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("fn main()")); +} + +#[test] +fn test_compile_qalloc() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "bell", + "params": [], + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("qalloc")); + assert!(result.contains("q")); +} + +#[test] +fn test_compile_gate() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_gate", + "params": [], + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("h q[0]")); +} + +#[test] +fn test_compile_two_qubit_gate() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "bell", + "params": [], + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + }, + { + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("h q[0]")); + assert!(result.contains("cx")); +} + +#[test] +fn test_compile_for_loop() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_loop", + "params": [], + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 4} + }, + { + "kind": "for", + "var": "i", + "range": { + "start": {"kind": "literal", "value": 0}, + "end": {"kind": "literal", "value": 4} + }, + "body": [ + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "ident", "name": "i"}} + ] + } + ] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("for")); + assert!(result.contains("0..4")); +} + +#[test] +fn test_compile_if_statement() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_if", + "params": [ + {"name": "cond", "type": {"kind": "primitive", "name": "bool"}} + ], + "body": [ + { + "kind": "if", + "condition": {"kind": "ident", "name": "cond"}, + "then_body": [ + {"kind": "return", "return_value": {"kind": "literal", "value": 1}} + ], + "else_body": [ + {"kind": "return", "return_value": {"kind": "literal", "value": 0}} + ] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + // Simple identifier conditions are converted to != 0 for u1/bool compatibility + assert!(result.contains("if (cond != 0)")); + assert!(result.contains("else")); +} + +#[test] +fn test_compile_function_with_params() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "add", + "params": [ + {"name": "a", "type": {"kind": "primitive", "name": "int"}}, + {"name": "b", "type": {"kind": "primitive", "name": "int"}} + ], + "return_type": {"kind": "primitive", "name": "int"}, + "body": [ + { + "kind": "return", + "return_value": { + "kind": "binary", + "op": "add", + "left": {"kind": "ident", "name": "a"}, + "right": {"kind": "ident", "name": "b"} + } + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("fn add(a: i64, b: i64)")); + assert!(result.contains("-> i64")); +} + +#[test] +fn test_compile_invalid_json() { + let ir = "not valid json"; + let result = compile(ir); + assert!(matches!(result, Err(CompileError::Parse(_)))); +} + +#[test] +fn test_compile_result_statement() { + // Test with proper measure statement (new IR format) + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "bell", + "params": [], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + }, + { + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] + }, + { + "kind": "measure", + "targets": [{"kind": "ident", "name": "q"}], + "results": ["m"] + }, + { + "kind": "result", + "tag": "measurements", + "value": {"kind": "ident", "name": "m"} + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("fn bell()")); + assert!(result.contains("-> unit")); + assert!(result.contains("qalloc(2)")); + assert!(result.contains("h q[0]")); + assert!(result.contains("cx")); + // Measure entire register: mz([2]u1) q + assert!(result.contains("mz([2]u1) q")); + assert!(result.contains(r#"result("measurements", m)"#)); + // Should have implicit return (return; is equivalent to return unit; in Zlup) + assert!(result.contains("return;")); +} + +#[test] +fn test_compile_single_qubit_measure() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_single", + "params": [], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 4} + }, + { + "kind": "measure", + "targets": [{"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}], + "results": ["m"] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + // Single qubit measurement: mz(u1) q[0] + assert!(result.contains("mz(u1) q[0]")); +} + +#[test] +fn test_compile_multiple_qubit_measure() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_multi", + "params": [], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 4} + }, + { + "kind": "measure", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ], + "results": ["m"] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + // Multiple explicit qubits: mz([2]u1) [q[0], q[1]] + assert!(result.contains("mz([2]u1) [q[0], q[1]]")); +} + +#[test] +fn test_compile_binding_statement() { + // Test binding (from annotated assignment like x: int = 5) + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_binding", + "params": [], + "return_type": {"kind": "primitive", "name": "int"}, + "body": [ + { + "kind": "binding", + "name": "x", + "type": {"kind": "primitive", "name": "int"}, + "value": {"kind": "literal", "value": 5}, + "is_mutable": true + }, + { + "kind": "return", + "return_value": {"kind": "ident", "name": "x"} + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("fn test_binding()")); + assert!(result.contains("mut x: i64 = 5")); + assert!(result.contains("return x")); +} diff --git a/exp/pecos-neo/Cargo.toml b/exp/pecos-neo/Cargo.toml index 0a4f57b8d..634c054e6 100644 --- a/exp/pecos-neo/Cargo.toml +++ b/exp/pecos-neo/Cargo.toml @@ -27,6 +27,7 @@ rayon.workspace = true num_cpus = "1.16" pecos-engines.workspace = true +pecos-results.workspace = true pecos-programs.workspace = true # Optional: for QASM support pecos-qasm = { workspace = true, optional = true } diff --git a/exp/pecos-neo/README.md b/exp/pecos-neo/README.md index f16a143ae..1c4b27f64 100644 --- a/exp/pecos-neo/README.md +++ b/exp/pecos-neo/README.md @@ -7,7 +7,7 @@ Composable quantum simulation with event-driven noise modeling. The `sim_neo` Tool API is the recommended entry point: ```rust -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_neo::command::CommandBuilder; let circuit = CommandBuilder::new() @@ -17,9 +17,9 @@ let circuit = CommandBuilder::new() .build(); // Run 1000 shots with depolarizing noise -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .depolarizing(0.01) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .run(); @@ -33,8 +33,8 @@ for outcome in &results.outcomes { - **Composable Noise**: Event-driven channels that combine freely -- depolarizing, measurement, idle, crosstalk, leakage, and custom channels - **Typed Commands**: `GateCommand` and `CommandQueue` with signal support for metadata alongside gates - **Plugin System**: ECS-inspired architecture for bundling simulation functionality -- **Parallel Execution**: Monte Carlo across multiple workers with `.workers(n)` -- **Advanced Sampling**: Importance sampling and subset simulation for rare event estimation +- **Parallel Execution**: Monte Carlo across multiple workers with `.sampling(monte_carlo(shots).workers(n))` +- **Advanced Sampling**: Importance sampling (with parallel workers) and subset simulation for rare event estimation; exhaustive path enumeration for measurement branches - **Extensible Gates**: `GateId`-based system with runtime overrides and decomposition - **Program Support**: Classical control engines (QASM, HUGR) with mid-circuit measurement and feedback - **State Vector**: Non-Clifford gates (T, rotations) via `state_vector()` backend diff --git a/exp/pecos-neo/docs/README.md b/exp/pecos-neo/docs/README.md index 29680c275..3c37383ae 100644 --- a/exp/pecos-neo/docs/README.md +++ b/exp/pecos-neo/docs/README.md @@ -3,7 +3,7 @@ Build a circuit, add noise, run it: ```rust -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_neo::command::CommandBuilder; let circuit = CommandBuilder::new() @@ -12,9 +12,9 @@ let circuit = CommandBuilder::new() .mz(0).mz(1) .build(); -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .depolarizing(0.01) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .run(); ``` @@ -30,18 +30,18 @@ Everything chains off `sim_neo()`. The sections below show what you can plug in. The simplest option -- uniform depolarizing noise: ```rust -sim_neo(circuit).depolarizing(0.01).shots(1000).run(); +sim_neo(circuit).auto().depolarizing(0.01).sampling(monte_carlo(1000)).run(); ``` Need per-channel control (single-qubit, two-qubit, measurement)? ```rust -sim_neo(circuit) +sim_neo(circuit).auto() .noise(GeneralNoiseModelBuilder::new() .with_p1(0.001) .with_p2(0.01) .with_p_meas_symmetric(0.005)) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); ``` @@ -57,15 +57,15 @@ Full guide: [Adding Noise](user-guides/noise.md) | Switch to the state vector backend for T gates, arbitrary rotations, etc.: ```rust -sim_neo(circuit).quantum(state_vector()).shots(1000).run(); +sim_neo(circuit).quantum(state_vector()).sampling(monte_carlo(1000)).run(); ``` ### Run in parallel -Add `.workers(n)` -- works with or without noise: +Add `.workers(n)` on the sampler -- works with or without noise: ```rust -sim_neo(circuit).depolarizing(0.01).workers(4).shots(10000).seed(42).run(); +sim_neo(circuit).auto().depolarizing(0.01).sampling(monte_carlo(10000).workers(4)).seed(42).run(); ``` ### Estimate rare event probabilities diff --git a/exp/pecos-neo/docs/dev/design-patterns.md b/exp/pecos-neo/docs/dev/design-patterns.md index 0654d4067..271a336a9 100644 --- a/exp/pecos-neo/docs/dev/design-patterns.md +++ b/exp/pecos-neo/docs/dev/design-patterns.md @@ -30,10 +30,11 @@ Need to run a quantum circuit simulation? ├─► Estimating rare event probabilities? │ │ │ ├─► P ~ 10^-3 to 10^-6? -│ │ └─► Use sim_neo() with importance_sampling() +│ │ └─► Use sim_neo() with importance_sampling(shots) │ │ │ └─► P ~ 10^-6 or smaller? -│ └─► Use SubsetSimulation or ProperSubsetSimulation +│ └─► Use sim_neo() with subset_simulation(samples).score(..).failure(..) +│ (or ProperSubsetSimulation directly for checkpoint continuation) │ └─► Need population-based simulation (splitting, cloning)? └─► Use World with ECS components @@ -52,58 +53,55 @@ Need to run a quantum circuit simulation? ### sim_neo() - The Recommended Entry Point ```rust -use pecos_neo::tool::{sim_neo, importance_sampling}; +use pecos_neo::tool::{importance_sampling, monte_carlo, sim_neo}; // Simple case -let results = sim_neo(circuit) - .shots(1000) +let results = sim_neo(circuit).auto() + .sampling(monte_carlo(1000)) .seed(42) .run(); // With noise -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .depolarizing(0.01) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); // With importance sampling -let results = sim_neo(circuit) - .sampling(importance_sampling() +let results = sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_p1(0.001) .with_boost(10.0)) - .shots(10000) .run(); // Parallel execution -let results = sim_neo(circuit) - .workers(4) - .shots(1000) +let results = sim_neo(circuit).auto() + .sampling(monte_carlo(1000).workers(4)) .run(); // Parallel execution with noise -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .depolarizing(0.01) - .workers(4) - .shots(1000) + .sampling(monte_carlo(1000).workers(4)) .run(); // With custom gate definitions let defs = GateDefinitions::new(); -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .gate_definitions(defs) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); // State vector with non-Clifford gates (rotations auto-enabled) let results = sim_neo(circuit) .quantum(state_vector()) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); // Control decomposition depth for deeply nested custom gates -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .max_decomp_depth(20) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); // Gate overrides (swap gate implementations at runtime) @@ -112,9 +110,9 @@ let overrides = GateOverrides::::new() // Custom implementation true }); -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .gate_overrides(overrides) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); ``` @@ -125,7 +123,7 @@ Event handlers (gate and signal handlers) can be passed through `sim_neo()` usin ```rust use pecos_neo::prelude::*; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -138,10 +136,9 @@ let handlers = EventHandlers::new() NoiseResponse::None }); -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .event_handlers(handlers) - .workers(4) // handlers are cloned per worker - .shots(1000) + .sampling(monte_carlo(1000).workers(4)) // handlers are cloned per worker .run(); ``` @@ -224,14 +221,13 @@ Complex configuration uses nested builders that compose naturally: ```rust // Top-level builder accepts nested builders sim_neo(circuit) - .sampling(importance_sampling() // Nested builder + .sampling(importance_sampling(10000) // Nested builder .with_p1(0.001) .with_boost(10.0)) .quantum(sparse_stab()) // Another nested builder .noise(GeneralNoiseModelBuilder::new() // And another .with_p1(0.001) .with_p2(0.01)) - .shots(1000) .run(); ``` @@ -239,9 +235,9 @@ sim_neo(circuit) | Parameters | Pattern | Example | |------------|---------|---------| -| 0-2 simple | Convenience method | `.shots(1000)`, `.seed(42)` | +| 0-2 simple | Convenience method | `.seed(42)`, `.qubits(5)` | | 0-2 with type | Free function → method | `.quantum(sparse_stab())` | -| 3+ related | Builder struct | `importance_sampling().with_p1().with_boost()` | +| 3+ related | Builder struct | `importance_sampling(shots).with_p1().with_boost()` | | Complex tree | Nested builders | Multiple levels of composition | ### Implementing New Builders @@ -388,10 +384,9 @@ trait objects. This enables parallel Monte Carlo execution for noisy circuits -- each worker gets an independent clone of the noise model: ```rust -sim_neo(circuit) +sim_neo(circuit).auto() .depolarizing(0.01) - .workers(4) // Each worker clones the noise model - .shots(10000) + .sampling(monte_carlo(10000).workers(4)) // Each worker clones the noise model .run(); ``` @@ -474,7 +469,8 @@ Entry-point builders use lowercase snake_case functions: ```rust pub fn sim_neo(input: impl SimNeoInput) -> SimNeoBuilder -pub fn importance_sampling() -> ImportanceSamplingBuilder +pub fn monte_carlo(shots: usize) -> MonteCarloBuilder +pub fn importance_sampling(shots: usize) -> ImportanceSamplingBuilder pub fn sparse_stab() -> SparseStabBuilder pub fn state_vector() -> StateVecBuilder ``` diff --git a/exp/pecos-neo/docs/dev/importance-sampling.md b/exp/pecos-neo/docs/dev/importance-sampling.md index b044bf133..9c829b996 100644 --- a/exp/pecos-neo/docs/dev/importance-sampling.md +++ b/exp/pecos-neo/docs/dev/importance-sampling.md @@ -13,7 +13,7 @@ Importance sampling instead: ## Quick Start with sim_neo -The easiest way to use importance sampling is via the `sim_neo` Tool API with the `importance_sampling()` builder: +The easiest way to use importance sampling is via the `sim_neo` Tool API with the `importance_sampling(shots)` builder: ```rust use pecos_neo::tool::{sim_neo, importance_sampling}; @@ -27,13 +27,12 @@ let circuit = CommandBuilder::new() .build(); // Run with importance sampling -let results = sim_neo(circuit) - .sampling(importance_sampling() +let results = sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_p1(0.001) // Single-qubit error rate .with_p2(0.01) // Two-qubit error rate .with_p_meas(0.001) // Measurement error rate .with_boost(10.0)) // Boost factor - .shots(10000) .seed(42) .run(); @@ -49,11 +48,10 @@ if let Some(error_rate) = results.weighted_mean(|outcome| { For uniform error rates, use the `with_uniform_error()` method: ```rust -let results = sim_neo(circuit) - .sampling(importance_sampling() +let results = sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_uniform_error(0.001) // Same rate for all gate types .with_boost(10.0)) - .shots(10000) .run(); ``` diff --git a/exp/pecos-neo/docs/dev/runner.md b/exp/pecos-neo/docs/dev/runner.md index a3ee77198..ce8bd48ac 100644 --- a/exp/pecos-neo/docs/dev/runner.md +++ b/exp/pecos-neo/docs/dev/runner.md @@ -296,7 +296,7 @@ let handlers = EventHandlers::new() .on_signal(|sig: &MySignal| { /* observe */ }); // Pass to sim_neo (cloned per worker in parallel mode) -sim_neo(circuit).event_handlers(handlers).workers(4).shots(1000).run(); +sim_neo(circuit).auto().event_handlers(handlers).sampling(monte_carlo(1000).workers(4)).run(); // Or merge into a CircuitRunner let runner = CircuitRunner::::new().with_event_handlers(handlers); diff --git a/exp/pecos-neo/docs/user-guides/events.md b/exp/pecos-neo/docs/user-guides/events.md index 38cb0b330..72d7cda21 100644 --- a/exp/pecos-neo/docs/user-guides/events.md +++ b/exp/pecos-neo/docs/user-guides/events.md @@ -37,7 +37,7 @@ let handlers = EventHandlers::new() NoiseResponse::None }); -sim_neo(circuit).event_handlers(handlers).shots(1000).run(); +sim_neo(circuit).auto().event_handlers(handlers).sampling(monte_carlo(1000)).run(); ``` Handlers return `NoiseResponse` to modify execution -- not just observe it: diff --git a/exp/pecos-neo/docs/user-guides/importance-sampling.md b/exp/pecos-neo/docs/user-guides/importance-sampling.md index 6d5d73f6a..f764e1d77 100644 --- a/exp/pecos-neo/docs/user-guides/importance-sampling.md +++ b/exp/pecos-neo/docs/user-guides/importance-sampling.md @@ -8,12 +8,11 @@ reweighting the results. Much more efficient than brute-force Monte Carlo. ```rust use pecos_neo::tool::{sim_neo, importance_sampling}; -let results = sim_neo(circuit) - .sampling(importance_sampling() +let results = sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_p1(0.001) // Single-qubit error rate .with_p2(0.01) // Two-qubit error rate .with_boost(10.0)) // Sample 10x more errors - .shots(10000) .seed(42) .run(); ``` @@ -21,11 +20,22 @@ let results = sim_neo(circuit) For uniform rates across all gate types: ```rust -sim_neo(circuit) - .sampling(importance_sampling() +sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_uniform_error(0.001) .with_boost(10.0)) - .shots(10000) + .run(); +``` + +Trials parallelize across workers; seeding is per global shot index, so +results are identical for any worker count: + +```rust +sim_neo(circuit).auto() + .sampling(importance_sampling(10000) + .with_uniform_error(0.001) + .with_boost(10.0) + .workers(8)) .run(); ``` diff --git a/exp/pecos-neo/docs/user-guides/noise-model.md b/exp/pecos-neo/docs/user-guides/noise-model.md index 440469a08..d945db14b 100644 --- a/exp/pecos-neo/docs/user-guides/noise-model.md +++ b/exp/pecos-neo/docs/user-guides/noise-model.md @@ -76,7 +76,7 @@ Pass the noise model to `sim_neo` or `CircuitRunner`: ```rust // Via sim_neo (handles shots, parallelism, seeding) -sim_neo(circuit).noise(noise).shots(1000).seed(42).run(); +sim_neo(circuit).auto().noise(noise).sampling(monte_carlo(1000)).seed(42).run(); // Via CircuitRunner (direct control) let mut runner = CircuitRunner::::new() @@ -89,7 +89,7 @@ let outcomes = runner.apply_circuit(&mut state, &circuit)?; independent copy with their own state: ```rust -sim_neo(circuit).noise(noise).workers(4).shots(10000).run(); +sim_neo(circuit).auto().noise(noise).sampling(monte_carlo(10000).workers(4)).run(); ``` ## Going Deeper diff --git a/exp/pecos-neo/docs/user-guides/noise.md b/exp/pecos-neo/docs/user-guides/noise.md index 38b702092..9f9536d68 100644 --- a/exp/pecos-neo/docs/user-guides/noise.md +++ b/exp/pecos-neo/docs/user-guides/noise.md @@ -5,7 +5,7 @@ One-liner on `sim_neo`: ```rust -sim_neo(circuit).depolarizing(0.01).shots(1000).run(); +sim_neo(circuit).auto().depolarizing(0.01).sampling(monte_carlo(1000)).run(); ``` ## Per-Channel Control @@ -13,12 +13,12 @@ sim_neo(circuit).depolarizing(0.01).shots(1000).run(); Set different rates for single-qubit, two-qubit, and measurement errors: ```rust -sim_neo(circuit) +sim_neo(circuit).auto() .noise(GeneralNoiseModelBuilder::new() .with_p1(0.001) .with_p2(0.01) .with_p_meas_symmetric(0.005)) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); ``` diff --git a/exp/pecos-neo/docs/user-guides/subset-simulation.md b/exp/pecos-neo/docs/user-guides/subset-simulation.md index f694d1610..6a0a7e087 100644 --- a/exp/pecos-neo/docs/user-guides/subset-simulation.md +++ b/exp/pecos-neo/docs/user-guides/subset-simulation.md @@ -17,19 +17,54 @@ instead of 1/P total samples. ## Quick Start +Subset simulation is a sampling strategy on `sim_neo()`, like +`monte_carlo()` and `importance_sampling()`. It needs two functions: +a score (how close is this outcome to failure?) and a failure predicate. +Both are required; the result arrives in `results.subset` (per-shot +`outcomes` are empty for subset runs). + ```rust -use pecos_neo::sampling::subset::{SubsetSimulation, SubsetConfig}; +use pecos_neo::tool::{sim_neo, sparse_stab, subset_simulation}; + +let results = sim_neo(circuit) + .quantum(sparse_stab()) // currently the only supported backend + .noise(noise) + .sampling( + subset_simulation(1000) // samples per level + // Higher score = closer to failure + .score(|outcomes| count_syndrome_errors(outcomes) as f64) + // What counts as failure? + .failure(|outcomes| is_logical_error(outcomes)), + ) + .seed(42) + .run(); + +let subset = results.subset.expect("subset strategy returns an estimate"); +println!("P(failure) = {:.2e}", subset.probability()); +println!("95% CI: {:?}", subset.confidence_interval_95()); +``` + +Requires a static circuit on the `sparse_stab()` backend; checked at +`.build()`. + +## Configuration + +```rust +subset_simulation(1000) // Samples per level (more = slower but more precise) + .score(score_fn) // Required: distance-to-failure metric + .failure(failure_fn) // Required: rare event predicate + .threshold_fraction(0.1) // Top 10% advance each level + .max_levels(20) // Safety limit on levels + .min_conditional_prob(1e-6) // Give up below this conditional probability +``` -// Define: what does "closer to failure" mean? -let score_fn = |outcomes: &MeasurementOutcomes| -> f64 { - // Higher score = closer to failure - count_syndrome_errors(outcomes) as f64 -}; +## Direct library API -// Define: what counts as failure? -let is_failure = |outcomes: &MeasurementOutcomes| -> bool { - is_logical_error(outcomes) -}; +For lower-level control (custom noise factories, checkpoint-continuation +variants like `ProperSubsetSimulation`), use the sampling module directly: + +```rust +use pecos_neo::sampling::subset::{SubsetSimulation, SubsetConfig}; let config = SubsetConfig::new() .with_samples_per_level(1000) @@ -39,19 +74,6 @@ let result = SubsetSimulation::new(circuit, num_qubits, score_fn, is_failure) .with_noise_builder(|| Some(noise.clone())) .with_config(config) .run(); - -println!("P(failure) = {:.2e}", result.probability()); -println!("95% CI: [{:.2e}, {:.2e}]", result.confidence_interval_95()); -``` - -## Configuration - -```rust -SubsetConfig::new() - .with_samples_per_level(1000) // Samples per level (more = slower but more precise) - .with_threshold_fraction(0.1) // Top 10% advance each level - .with_max_levels(20) // Safety limit on levels - .with_seed(42) // For reproducibility ``` ## When to Use diff --git a/exp/pecos-neo/examples/noise_cookbook.rs b/exp/pecos-neo/examples/noise_cookbook.rs index 4354dfe95..1cb3396e5 100644 --- a/exp/pecos-neo/examples/noise_cookbook.rs +++ b/exp/pecos-neo/examples/noise_cookbook.rs @@ -25,7 +25,7 @@ use pecos_neo::noise::prelude::*; use pecos_neo::prelude::*; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_simulators::SparseStab; fn main() { @@ -86,8 +86,9 @@ fn recipe_sim_neo_integration() { // Method 1: Pass pre-built pattern directly to .noise() let results = sim_neo(circuit.clone()) + .auto() .noise(depolarizing_with_measurement(0.01, 0.05, 0.02)) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .build() .run(); @@ -102,8 +103,9 @@ fn recipe_sim_neo_integration() { // Method 2: Use the convenience .depolarizing() method let results = sim_neo(circuit.clone()) + .auto() .depolarizing(0.01) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .build() .run(); @@ -121,13 +123,14 @@ fn recipe_sim_neo_integration() { // Method 3: Pass NoiseModelBuilder (auto-converts via Into) let results = sim_neo(circuit.clone()) + .auto() .noise( NoiseModelBuilder::new() .with_depolarizing(0.01, 0.05) .with_measurement_error(0.02) .build(), ) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .build() .run(); @@ -142,13 +145,14 @@ fn recipe_sim_neo_integration() { // Method 4: Reusable simulation with noise let mut sim = sim_neo(circuit) + .auto() .noise(realistic_device_noise( &DeviceNoiseParams::new() .with_p1(0.001) .with_p2(0.01) .with_measurement_error(0.02), )) - .shots(500) + .sampling(monte_carlo(500)) .build(); // Run multiple times with different seeds diff --git a/exp/pecos-neo/src/adapter.rs b/exp/pecos-neo/src/adapter.rs index 29e0ae177..86ba7933d 100644 --- a/exp/pecos-neo/src/adapter.rs +++ b/exp/pecos-neo/src/adapter.rs @@ -340,6 +340,14 @@ where fn num_qubits(&self) -> usize { self.num_qubits } + + fn shot_results(&self) -> Option { + Some( + self.engine + .get_results() + .expect("classical engine failed to produce results for a completed shot"), + ) + } } /// Runner adapter for executing pecos-neo command sources through a diff --git a/exp/pecos-neo/src/command.rs b/exp/pecos-neo/src/command.rs index a26c8b3a4..f66e05862 100644 --- a/exp/pecos-neo/src/command.rs +++ b/exp/pecos-neo/src/command.rs @@ -248,6 +248,88 @@ impl GateCommand { Self::new(GateType::I, smallvec::smallvec![qubit]) } + /// The inverse (dagger) of this gate, if it is an invertible unitary; + /// `None` otherwise (measurement, prep, idle, resource management). + /// + /// Used by the gate-removing emission noise model: when a gate suffers a + /// spontaneous-emission error, undoing the gate (`G dagger`) and then + /// applying the emission error reproduces engines' "the emission replaces + /// the gate" semantics, since `G * G_dagger = I`. + /// + /// Rotation inverses use the standard conventions + /// (`RX/RY/RZ(theta)` and `CRZ/RXX/RYY/RZZ(theta)` -> negate `theta`; + /// `R1XY(theta, phi)` -> `R1XY(-theta, phi)`; `U(theta, phi, lambda)` -> + /// `U(-theta, -lambda, -phi)`). + #[must_use] + pub fn dagger(&self) -> Option { + let q = self.qubits.clone(); + let same = |t: GateType| Some(Self::new(t, q.clone())); + let neg_first = |t: GateType| -> Option { + let theta = *self.angles.first()?; + Some(Self::with_angles(t, q.clone(), smallvec::smallvec![-theta])) + }; + match self.gate_type { + // Self-inverse gates (1q, 2q, 3q). + GateType::I + | GateType::X + | GateType::Y + | GateType::Z + | GateType::H + | GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SWAP + | GateType::CCX => same(self.gate_type), + // Dagger pairs. + GateType::F => same(GateType::Fdg), + GateType::Fdg => same(GateType::F), + GateType::SX => same(GateType::SXdg), + GateType::SXdg => same(GateType::SX), + GateType::SY => same(GateType::SYdg), + GateType::SYdg => same(GateType::SY), + GateType::SZ => same(GateType::SZdg), + GateType::SZdg => same(GateType::SZ), + GateType::T => same(GateType::Tdg), + GateType::Tdg => same(GateType::T), + GateType::SZZ => same(GateType::SZZdg), + GateType::SZZdg => same(GateType::SZZ), + GateType::SXX => same(GateType::SXXdg), + GateType::SXXdg => same(GateType::SXX), + GateType::SYY => same(GateType::SYYdg), + GateType::SYYdg => same(GateType::SYY), + // Single-angle rotations (1q and 2q): negate the angle. + GateType::RX + | GateType::RY + | GateType::RZ + | GateType::CRZ + | GateType::RXX + | GateType::RYY + | GateType::RZZ => neg_first(self.gate_type), + // R1XY(theta, phi) dagger = R1XY(-theta, phi). + GateType::R1XY => { + let theta = *self.angles.first()?; + let phi = *self.angles.get(1)?; + Some(Self::with_angles( + GateType::R1XY, + q, + smallvec::smallvec![-theta, phi], + )) + } + // U(theta, phi, lambda) dagger = U(-theta, -lambda, -phi). + GateType::U => { + let theta = *self.angles.first()?; + let phi = *self.angles.get(1)?; + let lambda = *self.angles.get(2)?; + Some(Self::with_angles( + GateType::U, + q, + smallvec::smallvec![-theta, -lambda, -phi], + )) + } + _ => None, + } + } + /// Create a Pauli-X gate on a qubit. #[must_use] pub fn x(qubit: QubitId) -> Self { diff --git a/exp/pecos-neo/src/noise.rs b/exp/pecos-neo/src/noise.rs index 7bf280ffb..4bd490a01 100644 --- a/exp/pecos-neo/src/noise.rs +++ b/exp/pecos-neo/src/noise.rs @@ -111,6 +111,7 @@ pub mod introspection; pub mod leakage; pub mod measurement; pub mod patterns; +pub mod per_gate_pauli; pub mod plugin; pub mod plugins; pub mod prelude; @@ -131,11 +132,12 @@ pub use gate_id_dependent::{GateIdDependentChannel, GateIdNoiseConfig}; pub use general_builder::{GeneralNoiseModelBuilder, general_noise}; pub use idle::IdleChannel; pub use leakage::LeakageChannel; -pub use measurement::MeasurementChannel; +pub use measurement::{MeasurementChannel, MeasurementStateFlipChannel}; +pub use per_gate_pauli::PerGatePauliChannel; pub use plugin::{ContextObserver, EventHandler, NoiseModelConfig, NoisePlugin}; pub use preparation::PreparationChannel; pub use single_qubit::SingleQubitChannel; -pub use two_qubit::TwoQubitChannel; +pub use two_qubit::{AngleScaling, TwoQubitChannel}; use crate::command::GateCommand; use crate::command::GateType; diff --git a/exp/pecos-neo/src/noise/composer.rs b/exp/pecos-neo/src/noise/composer.rs index e874055cd..a7ec9cd4b 100644 --- a/exp/pecos-neo/src/noise/composer.rs +++ b/exp/pecos-neo/src/noise/composer.rs @@ -511,7 +511,7 @@ impl From for ComposableNoiseModel { /// let circuit = CommandQueue::new(); /// /// // Both of these work: - /// sim_neo(circuit.clone()).noise(GeneralNoiseModelBuilder::new().with_p1(0.01).build()); + /// sim_neo(circuit.clone()).auto().noise(GeneralNoiseModelBuilder::new().with_p1(0.01).build()); /// sim_neo(circuit).noise(GeneralNoiseModelBuilder::new().with_p1(0.01)); // No .build()! /// ``` fn from(builder: super::GeneralNoiseModelBuilder) -> Self { diff --git a/exp/pecos-neo/src/noise/general_builder.rs b/exp/pecos-neo/src/noise/general_builder.rs index d55c2ef10..6033c727f 100644 --- a/exp/pecos-neo/src/noise/general_builder.rs +++ b/exp/pecos-neo/src/noise/general_builder.rs @@ -38,7 +38,7 @@ use super::crosstalk::CrosstalkChannel; use super::idle::IdleChannel; use super::leakage::LeakageChannel; -use super::measurement::MeasurementChannel; +use super::measurement::{MeasurementChannel, MeasurementStateFlipChannel}; use super::plugins::CorePlugin; use super::preparation::PreparationChannel; use super::single_qubit::SingleQubitChannel; @@ -101,6 +101,7 @@ pub struct GeneralNoiseModelBuilder { // Measurement p_meas_0: f64, p_meas_1: f64, + p_meas_state_flip: f64, p_meas_crosstalk_global: f64, p_meas_crosstalk_local: f64, p_meas_crosstalk_transitions: Option, @@ -160,6 +161,7 @@ impl GeneralNoiseModelBuilder { // Measurement p_meas_0: 0.0, p_meas_1: 0.0, + p_meas_state_flip: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_local: 0.0, p_meas_crosstalk_transitions: None, @@ -353,6 +355,17 @@ impl GeneralNoiseModelBuilder { self } + /// Set a measurement error realized as a physical X flip of the + /// qubit state just before readout (engines depolarizing / DEM + /// convention). Unlike `with_p_meas_symmetric`, the error persists + /// in the post-measurement state: measuring the same qubit twice + /// without a reset sees the second outcome flipped at `2p(1-p)`. + #[must_use] + pub fn with_p_meas_state_flip(mut self, p: f64) -> Self { + self.p_meas_state_flip = p; + self + } + /// Set measurement crosstalk probabilities (global and local). #[must_use] pub fn with_p_meas_crosstalk(mut self, global: f64, local: f64) -> Self { @@ -557,6 +570,9 @@ impl GeneralNoiseModelBuilder { } // Measurement channel + if self.p_meas_state_flip > 0.0 { + model = model.add_channel(MeasurementStateFlipChannel::new(self.p_meas_state_flip)); + } if self.p_meas_0 > 0.0 || self.p_meas_1 > 0.0 { model = model.add_channel(MeasurementChannel::asymmetric(self.p_meas_0, self.p_meas_1)); } diff --git a/exp/pecos-neo/src/noise/measurement.rs b/exp/pecos-neo/src/noise/measurement.rs index 9001d8cbb..28b2a93f6 100644 --- a/exp/pecos-neo/src/noise/measurement.rs +++ b/exp/pecos-neo/src/noise/measurement.rs @@ -209,11 +209,147 @@ impl NoiseChannel for MeasurementChannel { } } +/// Measurement error realized as a physical X flip of the qubit state +/// just before readout. +/// +/// Unlike [`MeasurementChannel`], which flips only the classical record +/// (the post-measurement state is untouched), this channel injects an X +/// gate before the measurement executes, so the error persists in the +/// state and propagates to later operations on the same qubit. This is +/// the convention of the engines stack's depolarizing noise family and +/// of the DEM builder's measurement mechanisms: measuring the same qubit +/// twice without a reset sees the second outcome flipped at rate +/// `2p(1-p)`, not `p`. +/// +/// State flips are inherently symmetric (X swaps |0> and |1>), so there +/// is no asymmetric variant; use [`MeasurementChannel`] for asymmetric +/// readout-record errors. +#[derive(Debug, Clone)] +pub struct MeasurementStateFlipChannel { + /// Probability of an X flip before each measurement. + pub p: f64, + /// Precomputed probability threshold for fast sampling. + threshold: u64, +} + +impl MeasurementStateFlipChannel { + /// Create a state-flip measurement error channel. + #[must_use] + pub fn new(p: f64) -> Self { + Self { + p, + threshold: PecosRng::probability_threshold(p), + } + } +} + +impl NoiseChannel for MeasurementStateFlipChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + if self.p <= 0.0 { + return false; + } + matches!(event, NoiseEvent::BeforeMeasurement { .. }) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + ctx: &mut NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let NoiseEvent::BeforeMeasurement { qubits } = event else { + return NoiseResponse::None; + }; + + let mut gates: SmallVec<[crate::command::GateCommand; 4]> = SmallVec::new(); + let has_any_leakage = ctx.leaked_count() > 0; + + for &qubit in *qubits { + if has_any_leakage && ctx.is_leaked(qubit) { + continue; + } + if rng.check_probability(self.threshold) { + gates.push(crate::command::GateCommand::new( + crate::command::GateType::X, + smallvec::smallvec![qubit], + )); + } + } + + if gates.is_empty() { + NoiseResponse::None + } else { + NoiseResponse::inject_gates(gates) + } + } + + fn name(&self) -> &'static str { + "MeasurementStateFlipChannel" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + #[cfg(test)] mod tests { use super::*; use pecos_core::QubitId; + /// The state-flip vs record-flip distinction: measuring the same + /// qubit twice without a reset. A record flip leaves the state + /// untouched (second outcome flips at p); a state flip persists + /// (second outcome flips at 2p(1-p)). + #[test] + #[allow(clippy::cast_precision_loss)] + fn state_flip_propagates_to_second_measurement_record_flip_does_not() { + use crate::prelude::*; + use pecos_simulators::SparseStab; + + const SHOTS: usize = 20_000; + let p = 0.25; + + let commands = CommandBuilder::new().pz(&[0]).mz(&[0]).mz(&[0]).build(); + + let second_meas_rate = |model: ComposableNoiseModel| -> f64 { + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let mut ones = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); + let bits: Vec = outcomes.iter().map(|o| o.outcome).collect(); + assert_eq!(bits.len(), 2); + if bits[1] { + ones += 1; + } + } + ones as f64 / SHOTS as f64 + }; + + let five_sigma = |q: f64| 5.0 * (q * (1.0 - q) / SHOTS as f64).sqrt(); + + let state_flip = second_meas_rate( + ComposableNoiseModel::new().add_channel(MeasurementStateFlipChannel::new(p)), + ); + let expected_state = 2.0 * p * (1.0 - p); + assert!( + (state_flip - expected_state).abs() < five_sigma(expected_state), + "state flip second-measure rate: got {state_flip}, expected {expected_state}" + ); + + let record_flip = second_meas_rate( + ComposableNoiseModel::new().add_channel(MeasurementChannel::symmetric(p)), + ); + assert!( + (record_flip - p).abs() < five_sigma(p), + "record flip second-measure rate: got {record_flip}, expected {p}" + ); + } + #[test] fn test_symmetric_measurement_error() { let channel = MeasurementChannel::symmetric(1.0); // Always flip diff --git a/exp/pecos-neo/src/noise/per_gate_pauli.rs b/exp/pecos-neo/src/noise/per_gate_pauli.rs new file mode 100644 index 000000000..bcd64ddde --- /dev/null +++ b/exp/pecos-neo/src/noise/per_gate_pauli.rs @@ -0,0 +1,568 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Per-gate-type, optionally per-qubit Pauli noise. +//! +//! Mirrors pecos-qec's `PerGateTypeNoise` layered lookup so circuit-level +//! Monte Carlo on this stack can run the same noise that drives DEM +//! generation: +//! +//! ```text +//! 1. per-(gate, qubit) rates // most specific +//! 2. per-gate-type rates +//! 3. base p1/3 (or p2/15) uniform // fallback +//! ``` +//! +//! Rates are absolute per-Pauli probabilities (NOT normalized weights): +//! a `[f64; 3]` entry is `[P(X), P(Y), P(Z)]` and the gate's total error +//! probability is the sum. Two-qubit arrays follow [`TWO_QUBIT_PAULIS`] +//! ordering. Idle noise is out of scope here — compose with +//! [`super::idle::IdleChannel`] for T1/T2 effects. + +use super::{NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse, TWO_QUBIT_PAULIS}; +use crate::command::{GateCommand, GateType}; +use pecos_core::QubitId; +use pecos_random::PecosRng; +use rand::RngExt; +use smallvec::SmallVec; +use std::collections::BTreeMap; + +/// Per-gate-type Pauli channel with per-qubit overrides. +/// +/// See the module docs for the lookup order and rate conventions. +#[derive(Debug, Clone, Default)] +pub struct PerGatePauliChannel { + rates_1q: BTreeMap, + rates_2q: BTreeMap, + rates_1q_per_qubit: BTreeMap<(GateType, QubitId), [f64; 3]>, + rates_2q_per_qubits: BTreeMap<(GateType, QubitId, QubitId), [f64; 15]>, + measurement_rates: BTreeMap, + init_rates: BTreeMap, + p_meas: f64, + p_init: f64, + base_p1: f64, + base_p2: f64, +} + +fn assert_probability_vector(rates: &[f64], what: &str) { + let total: f64 = rates.iter().sum(); + assert!( + rates.iter().all(|&r| r >= 0.0) && total <= 1.0 + 1e-9, + "{what} must be non-negative probabilities summing to at most 1.0, got total {total}" + ); +} + +impl PerGatePauliChannel { + /// Create an empty channel: no per-gate entries, zero base rates. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the uniform base rates used for gates with no explicit entry. + /// + /// `p1`/`p2` are total error probabilities, split uniformly over the + /// 3 (15) Paulis, matching pecos-qec's `NoiseConfig` fallback. + #[must_use] + pub fn with_base(mut self, p1: f64, p2: f64) -> Self { + assert_probability_vector(&[p1], "base p1"); + assert_probability_vector(&[p2], "base p2"); + self.base_p1 = p1; + self.base_p2 = p2; + self + } + + /// Set the default measurement and preparation error rates. + /// + /// The measurement error is a physical X injected before readout (it + /// propagates into the post-measurement state, matching the DEM's + /// measurement-fault convention), and the preparation error is a + /// physical X after prep. + #[must_use] + pub fn with_meas_init(mut self, p_meas: f64, p_init: f64) -> Self { + assert_probability_vector(&[p_meas], "p_meas"); + assert_probability_vector(&[p_init], "p_init"); + self.p_meas = p_meas; + self.p_init = p_init; + self + } + + /// Attach `[P(X), P(Y), P(Z)]` rates for a 1q gate type on any qubit. + #[must_use] + pub fn with_1q_rates(mut self, gate: GateType, rates: [f64; 3]) -> Self { + assert_probability_vector(&rates, "1q rates"); + self.rates_1q.insert(gate, rates); + self + } + + /// Attach rates for a 2q gate type on any qubit pair, ordered by + /// [`TWO_QUBIT_PAULIS`]. + #[must_use] + pub fn with_2q_rates(mut self, gate: GateType, rates: [f64; 15]) -> Self { + assert_probability_vector(&rates, "2q rates"); + self.rates_2q.insert(gate, rates); + self + } + + /// Attach `[P(X), P(Y), P(Z)]` rates for a 1q gate type on one qubit. + /// Takes precedence over [`Self::with_1q_rates`] for that pair. + #[must_use] + pub fn with_1q_rates_for_qubit( + mut self, + gate: GateType, + qubit: QubitId, + rates: [f64; 3], + ) -> Self { + assert_probability_vector(&rates, "1q per-qubit rates"); + self.rates_1q_per_qubit.insert((gate, qubit), rates); + self + } + + /// Attach rates for a 2q gate type on one ordered qubit pair, ordered + /// by [`TWO_QUBIT_PAULIS`]. Takes precedence over + /// [`Self::with_2q_rates`] for that combination. + #[must_use] + pub fn with_2q_rates_for_qubits( + mut self, + gate: GateType, + first: QubitId, + second: QubitId, + rates: [f64; 15], + ) -> Self { + assert_probability_vector(&rates, "2q per-qubit rates"); + self.rates_2q_per_qubits + .insert((gate, first, second), rates); + self + } + + /// Set a per-qubit measurement X-flip probability (overrides the + /// default from [`Self::with_meas_init`] for that qubit). + #[must_use] + pub fn with_meas_rate_for_qubit(mut self, qubit: QubitId, p: f64) -> Self { + assert_probability_vector(&[p], "per-qubit meas rate"); + self.measurement_rates.insert(qubit, p); + self + } + + /// Set a per-qubit preparation X-error probability (overrides the + /// default from [`Self::with_meas_init`] for that qubit). + #[must_use] + pub fn with_init_rate_for_qubit(mut self, qubit: QubitId, p: f64) -> Self { + assert_probability_vector(&[p], "per-qubit init rate"); + self.init_rates.insert(qubit, p); + self + } + + /// Resolve the 1q rates for a gate on a qubit via the layered lookup. + fn rates_1q_for(&self, gate: GateType, qubit: QubitId) -> [f64; 3] { + if let Some(rates) = self.rates_1q_per_qubit.get(&(gate, qubit)) { + return *rates; + } + if let Some(rates) = self.rates_1q.get(&gate) { + return *rates; + } + [self.base_p1 / 3.0; 3] + } + + /// Resolve the 2q rates for a gate on an ordered pair. + fn rates_2q_for(&self, gate: GateType, first: QubitId, second: QubitId) -> [f64; 15] { + if let Some(rates) = self.rates_2q_per_qubits.get(&(gate, first, second)) { + return *rates; + } + if let Some(rates) = self.rates_2q.get(&gate) { + return *rates; + } + [self.base_p2 / 15.0; 15] + } + + fn has_any_gate_noise(&self) -> bool { + self.base_p1 > 0.0 + || self.base_p2 > 0.0 + || !self.rates_1q.is_empty() + || !self.rates_2q.is_empty() + || !self.rates_1q_per_qubit.is_empty() + || !self.rates_2q_per_qubits.is_empty() + } + + fn apply_after_gate( + &self, + gate_type: GateType, + qubits: &[QubitId], + ctx: &NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + if ctx.is_noiseless(gate_type) { + return NoiseResponse::None; + } + + let mut gates: SmallVec<[GateCommand; 4]> = SmallVec::new(); + if gate_type.is_single_qubit() { + for &qubit in qubits { + if ctx.is_leaked(qubit) { + continue; + } + let [px, py, pz] = self.rates_1q_for(gate_type, qubit); + let r = rng.random::(); + let pauli = if r < px { + GateType::X + } else if r < px + py { + GateType::Y + } else if r < px + py + pz { + GateType::Z + } else { + continue; + }; + gates.push(GateCommand::new(pauli, smallvec::smallvec![qubit])); + } + } else if qubits.len() == 2 { + let (first, second) = (qubits[0], qubits[1]); + if !ctx.is_leaked(first) && !ctx.is_leaked(second) { + let rates = self.rates_2q_for(gate_type, first, second); + let r = rng.random::(); + let mut cumulative = 0.0; + for (idx, &p) in rates.iter().enumerate() { + cumulative += p; + if r < cumulative { + let (pauli0, pauli1) = TWO_QUBIT_PAULIS[idx]; + if pauli0 != GateType::I { + gates.push(GateCommand::new(pauli0, smallvec::smallvec![first])); + } + if pauli1 != GateType::I { + gates.push(GateCommand::new(pauli1, smallvec::smallvec![second])); + } + break; + } + } + } + } + + if gates.is_empty() { + NoiseResponse::None + } else { + NoiseResponse::inject_gates(gates) + } + } + + fn apply_before_measurement( + &self, + qubits: &[QubitId], + ctx: &NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + // The DEM models a measurement error as a physical X at the + // measurement location (see pecos-qec's + // `process_meas_fault_source_tracked`: "Measurement error is a + // bit flip (X error)"), which propagates into the post-measurement + // state. Inject X before readout to match that convention, so this + // channel runs the same measurement physics the DEM encodes — + // a re-measured (un-reset) qubit flips at 2p(1-p), not p. + let mut gates: SmallVec<[GateCommand; 4]> = SmallVec::new(); + for &qubit in qubits { + if ctx.is_leaked(qubit) { + continue; + } + let p = *self.measurement_rates.get(&qubit).unwrap_or(&self.p_meas); + if p > 0.0 && rng.random::() < p { + gates.push(GateCommand::new(GateType::X, smallvec::smallvec![qubit])); + } + } + if gates.is_empty() { + NoiseResponse::None + } else { + NoiseResponse::inject_gates(gates) + } + } + + fn apply_after_preparation( + &self, + qubits: &[QubitId], + ctx: &NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let mut gates: SmallVec<[GateCommand; 4]> = SmallVec::new(); + for &qubit in qubits { + if ctx.is_leaked(qubit) { + continue; + } + let p = *self.init_rates.get(&qubit).unwrap_or(&self.p_init); + if p > 0.0 && rng.random::() < p { + gates.push(GateCommand::new(GateType::X, smallvec::smallvec![qubit])); + } + } + if gates.is_empty() { + NoiseResponse::None + } else { + NoiseResponse::inject_gates(gates) + } + } +} + +impl NoiseChannel for PerGatePauliChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + match event { + NoiseEvent::AfterGate { gate_type, .. } => { + gate_type.is_unitary_gate() + && (gate_type.is_single_qubit() || gate_type.is_two_qubit()) + && self.has_any_gate_noise() + } + NoiseEvent::BeforeMeasurement { .. } => { + self.p_meas > 0.0 || !self.measurement_rates.is_empty() + } + NoiseEvent::AfterPreparation { .. } => self.p_init > 0.0 || !self.init_rates.is_empty(), + _ => false, + } + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + ctx: &mut NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + match event { + NoiseEvent::AfterGate { + gate_type, qubits, .. + } => self.apply_after_gate(*gate_type, qubits, ctx, rng), + NoiseEvent::BeforeMeasurement { qubits } => { + self.apply_before_measurement(qubits, ctx, rng) + } + NoiseEvent::AfterPreparation { qubits } => { + self.apply_after_preparation(qubits, ctx, rng) + } + _ => NoiseResponse::None, + } + } + + fn name(&self) -> &'static str { + "PerGatePauliChannel" + } + + fn priority(&self) -> i32 { + 10 + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +// statistical tests use count as f64 +#[allow(clippy::cast_precision_loss)] +mod tests { + use super::*; + use crate::noise::ComposableNoiseModel; + use crate::prelude::*; + use pecos_simulators::SparseStab; + + const SHOTS: usize = 20_000; + + fn flip_rate(model: ComposableNoiseModel, commands: &CommandQueue, qubit: usize) -> f64 { + let mut state = SparseStab::new(2); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let qubits = [QubitId(qubit)]; + let mut flips = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, commands).unwrap(); + if let Some(bits) = outcomes.bitstring(&qubits) + && bits[0] + { + flips += 1; + } + } + flips as f64 / SHOTS as f64 + } + + fn five_sigma(p: f64) -> f64 { + 5.0 * (p * (1.0 - p) / SHOTS as f64).sqrt() + } + + #[test] + fn per_gate_rates_apply_to_that_gate_only() { + // X-only error on H; the X gate stays noiseless. + let channel = PerGatePauliChannel::new().with_1q_rates(GateType::H, [0.2, 0.0, 0.0]); + let model = ComposableNoiseModel::new().add_channel(channel); + + // H twice returns to |0>; an injected X after either H flips the + // outcome unless both Hs get one (prob 0.2*0.2 cancels via parity: + // P(flip) = 2*0.2*0.8 = 0.32). + let commands = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) + .h(&[0]) + .mz(&[0]) + .build(); + let rate = flip_rate(model.clone(), &commands, 0); + // X after the second H flips Z-outcome directly; X after the first H + // becomes Z through the second H... careful: track exactly. + // |0> -H-> |+> -X-> |+> (X|+> = |+>): an X after the FIRST H does + // nothing to the final outcome. After the second H the state is |0>, + // X flips it. So P(flip) = 0.2 exactly. + assert!( + (rate - 0.2).abs() < five_sigma(0.2), + "H-specific X rate: got {rate}, expected 0.2" + ); + + // The X gate has no entry and base is zero: noiseless. + let channel = PerGatePauliChannel::new().with_1q_rates(GateType::H, [0.2, 0.0, 0.0]); + let model = ComposableNoiseModel::new().add_channel(channel); + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let rate = flip_rate(model, &commands, 0); + assert!( + (rate - 1.0).abs() < f64::EPSILON, + "X gate must stay noiseless, got outcome-1 rate {rate}" + ); + } + + #[test] + fn per_qubit_rates_override_per_gate_rates() { + // Gate-level: 30% X error on X gates; qubit 0 override: 0%. + let channel = PerGatePauliChannel::new() + .with_1q_rates(GateType::X, [0.3, 0.0, 0.0]) + .with_1q_rates_for_qubit(GateType::X, QubitId(0), [0.0; 3]); + let model = ComposableNoiseModel::new().add_channel(channel); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .x(&[0]) + .x(&[1]) + .mz(&[0]) + .mz(&[1]) + .build(); + + // Qubit 0: override says noiseless -> outcome always 1. + let rate0 = 1.0 - flip_rate(model.clone(), &commands, 0); + assert!( + rate0.abs() < f64::EPSILON, + "qubit 0 override must be noiseless, got flip rate {rate0}" + ); + // Qubit 1: per-gate 30% X error cancels the X gate -> outcome 0. + let rate1 = 1.0 - flip_rate(model, &commands, 1); + assert!( + (rate1 - 0.3).abs() < five_sigma(0.3), + "qubit 1 per-gate rate: got {rate1}, expected 0.3" + ); + } + + #[test] + fn two_qubit_orientation_first_pauli_hits_first_qubit() { + // XI-only error on CX(0, 1): qubit 0 flips at 25%, qubit 1 never + // (after CX from |00>, X on control would propagate only if it + // happened BEFORE the gate; injection is after, so it stays local). + let mut rates = [0.0; 15]; + rates[0] = 0.25; // (X, I) in TWO_QUBIT_PAULIS order + let channel = PerGatePauliChannel::new().with_2q_rates(GateType::CX, rates); + let model = ComposableNoiseModel::new().add_channel(channel); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .cx(&[(0, 1)]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = flip_rate(model.clone(), &commands, 0); + let rate1 = flip_rate(model, &commands, 1); + assert!( + (rate0 - 0.25).abs() < five_sigma(0.25), + "first qubit must flip at the XI rate, got {rate0}" + ); + assert!( + rate1.abs() < f64::EPSILON, + "second qubit must be untouched by XI, got {rate1}" + ); + } + + #[test] + fn measurement_and_init_rates_with_per_qubit_overrides() { + let channel = PerGatePauliChannel::new() + .with_meas_init(0.1, 0.0) + .with_meas_rate_for_qubit(QubitId(1), 0.4); + let model = ComposableNoiseModel::new().add_channel(channel); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = flip_rate(model.clone(), &commands, 0); + let rate1 = flip_rate(model, &commands, 1); + assert!( + (rate0 - 0.1).abs() < five_sigma(0.1), + "default meas rate: got {rate0}, expected 0.1" + ); + assert!( + (rate1 - 0.4).abs() < five_sigma(0.4), + "per-qubit meas rate: got {rate1}, expected 0.4" + ); + } + + #[test] + fn measurement_error_propagates_to_a_re_measured_qubit() { + // The measurement error is a physical X before readout (DEM + // convention), so measuring the same qubit twice without a reset + // sees the second outcome flipped at 2p(1-p), not p. A record-only + // flip (the bug this guards) would give p. + let p = 0.25; + let channel = PerGatePauliChannel::new().with_meas_init(p, 0.0); + let model = ComposableNoiseModel::new().add_channel(channel); + + let commands = CommandBuilder::new().pz(&[0]).mz(&[0]).mz(&[0]).build(); + let mut state = SparseStab::new(2); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let mut second_ones = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); + let bits: Vec = outcomes.iter().map(|o| o.outcome).collect(); + assert_eq!(bits.len(), 2); + if bits[1] { + second_ones += 1; + } + } + let rate = second_ones as f64 / SHOTS as f64; + let expected = 2.0 * p * (1.0 - p); + assert!( + (rate - expected).abs() < five_sigma(expected), + "second-measure rate: got {rate}, expected {expected}" + ); + } + + #[test] + fn base_rates_back_fill_unlisted_gates() { + // base p1 = 0.3 -> uniform X/Y/Z at 0.1 each; X and Y flip the + // Z-basis outcome after an X gate, so P(outcome = 1) = 0.8. + let channel = PerGatePauliChannel::new().with_base(0.3, 0.0); + let model = ComposableNoiseModel::new().add_channel(channel); + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let rate = flip_rate(model, &commands, 0); + assert!( + (rate - 0.8).abs() < five_sigma(0.8), + "base fallback: got outcome-1 rate {rate}, expected 0.8" + ); + } + + #[test] + #[should_panic(expected = "must be non-negative probabilities")] + fn rejects_rates_summing_above_one() { + let _ = PerGatePauliChannel::new().with_1q_rates(GateType::H, [0.5, 0.4, 0.3]); + } +} diff --git a/exp/pecos-neo/src/noise/single_qubit.rs b/exp/pecos-neo/src/noise/single_qubit.rs index 1cfa6342c..a2a1116a7 100644 --- a/exp/pecos-neo/src/noise/single_qubit.rs +++ b/exp/pecos-neo/src/noise/single_qubit.rs @@ -37,7 +37,8 @@ use super::{ NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse, PauliWeights, SingleQubitEmissionResult, SingleQubitEmissionWeights, }; -use crate::command::GateCommand; +use crate::command::{GateCommand, GateType}; +use pecos_core::Angle64; use pecos_random::PecosRng; use rand::RngExt; use smallvec::SmallVec; @@ -245,13 +246,16 @@ impl NoiseChannel for SingleQubitChannel { Self::handle_before_gate(qubits, ctx, rng) } NoiseEvent::AfterGate { - gate_type, qubits, .. + gate_type, + qubits, + angles, + .. } => { // Skip noise for noiseless gates if ctx.is_noiseless(*gate_type) { return NoiseResponse::None; } - self.handle_after_gate(qubits, ctx, rng) + self.handle_after_gate(*gate_type, qubits, angles, ctx, rng) } _ => NoiseResponse::None, } @@ -284,7 +288,10 @@ impl NoiseChannel for SingleQubitChannel { Some(Self::handle_before_gate(qubits, ctx, rng)) } NoiseEvent::AfterGate { - gate_type, qubits, .. + gate_type, + qubits, + angles, + .. } => { if !gate_type.is_single_qubit() || !gate_type.is_unitary_gate() { return None; @@ -293,7 +300,7 @@ impl NoiseChannel for SingleQubitChannel { if ctx.is_noiseless(*gate_type) { return Some(NoiseResponse::None); } - Some(self.handle_after_gate(qubits, ctx, rng)) + Some(self.handle_after_gate(*gate_type, qubits, angles, ctx, rng)) } _ => None, } @@ -329,9 +336,15 @@ impl SingleQubitChannel { } /// Handle `AfterGate` event - apply Pauli or emission errors. + /// + /// `gate_type`/`angles` describe the gate just applied; an emission error + /// undoes it (applies its dagger) before the emission error, matching + /// engines' gate-replacing emission semantics. fn handle_after_gate( &self, + gate_type: GateType, qubits: &[pecos_core::QubitId], + angles: &[Angle64], ctx: &mut NoiseContext, rng: &mut PecosRng, ) -> NoiseResponse { @@ -362,6 +375,18 @@ impl SingleQubitChannel { if rng.check_probability(self.error_threshold) { // Determine if this is an emission or Pauli error if self.emission_ratio > 0.0 && rng.check_probability(self.emission_threshold) { + // Emission REPLACES the gate: undo it (apply its dagger) so + // the net effect is the gate removed, then apply the + // emission error. (Matches engines' apply_sq_faults, which + // drops the original gate on a spontaneous-emission fault.) + let original = GateCommand::with_angles( + gate_type, + smallvec::smallvec![qubit], + angles.iter().copied().collect::>(), + ); + if let Some(dagger) = original.dagger() { + gates.push(dagger); + } // Emission error - sample from emission weights match self.emission_weights.sample(rng.random::()) { SingleQubitEmissionResult::Pauli(pauli) => { @@ -458,8 +483,29 @@ mod tests { let mut rng = PecosRng::seed_from_u64(42); let response = channel.apply(&event, &mut ctx, &mut rng); - // With emission_ratio=1.0 and leakage_probability=1.0, should cause leakage - assert!(matches!(response, NoiseResponse::MarkLeaked(_))); + // emission_ratio=1.0 + leakage=1.0 marks the qubit leaked. Emission also + // REPLACES the gate, so the response now also injects the gate's dagger + // (H dagger = H) -> the combined response is `Multiple`. Assert it both + // marks the qubit leaked AND undoes the gate. + let (marks_leaked, undoes_gate) = match &response { + NoiseResponse::MarkLeaked(_) => (true, false), + NoiseResponse::Multiple(rs) => ( + rs.iter().any(|r| matches!(r, NoiseResponse::MarkLeaked(_))), + rs.iter().any(|r| { + matches!(r, NoiseResponse::InjectGates(gs) + if gs.iter().any(|g| g.gate_type == GateType::H)) + }), + ), + _ => (false, false), + }; + assert!( + marks_leaked, + "emission with leakage should mark the qubit leaked, got {response:?}" + ); + assert!( + undoes_gate, + "emission should undo the gate (inject its dagger), got {response:?}" + ); } #[test] diff --git a/exp/pecos-neo/src/noise/two_qubit.rs b/exp/pecos-neo/src/noise/two_qubit.rs index c5770993c..857964ec1 100644 --- a/exp/pecos-neo/src/noise/two_qubit.rs +++ b/exp/pecos-neo/src/noise/two_qubit.rs @@ -607,6 +607,18 @@ impl TwoQubitChannel { // Determine if this is an emission or Pauli error (using precomputed threshold) if self.emission_ratio > 0.0 && rng.check_probability(self.emission_threshold) { + // Emission REPLACES the gate: undo it (apply its dagger) so the net + // effect is the gate removed, then apply the emission error. (Matches + // engines' apply_tq_faults, which drops the original two-qubit gate + // on a spontaneous-emission fault.) + let original = GateCommand::with_angles( + gate_type, + smallvec::smallvec![qubit0, qubit1], + angles.iter().copied().collect::>(), + ); + if let Some(dagger) = original.dagger() { + gates.push(dagger); + } // Emission error - sample from emission weights let idx = self.emission_weights.sample(rng.random::()); let result = TwoQubitEmissionWeights::get_result(idx); diff --git a/exp/pecos-neo/src/program.rs b/exp/pecos-neo/src/program.rs index 6e7108590..4580db75f 100644 --- a/exp/pecos-neo/src/program.rs +++ b/exp/pecos-neo/src/program.rs @@ -110,6 +110,18 @@ pub trait CommandSource { /// Get the number of qubits required. fn num_qubits(&self) -> usize; + + /// Rich results for the just-completed shot, if this source produces + /// them. + /// + /// Classical engines accumulate named register values (e.g. QASM cregs) + /// during a shot and return them here as a [`pecos_results::Shot`]. + /// Sources without register data (static circuits, plain command + /// queues) return `None`; their per-qubit bits live in + /// `MeasurementOutcomes` instead. + fn shot_results(&self) -> Option { + None + } } /// Result of a single program execution (shot). diff --git a/exp/pecos-neo/src/runner.rs b/exp/pecos-neo/src/runner.rs index 08094abfe..e16b73b30 100644 --- a/exp/pecos-neo/src/runner.rs +++ b/exp/pecos-neo/src/runner.rs @@ -22,8 +22,13 @@ //! 1. **Overrides**: Custom executors registered via `GateOverrides` //! 2. **Clifford trait methods**: Core Clifford gates via `CliffordGateable` //! 3. **Rotation trait methods**: If `rotations()` constructor was used -//! 4. **Decomposition**: Expand using `GateDefinitions` -//! 5. **Error**: If none of the above apply +//! 4. **Clifford-angle rotations**: Automatic fallback that runs a +//! rotation gate at a Clifford angle (e.g. `RZ(pi/2)` as `S`) on a +//! Clifford backend — but ONLY when the gate has no user-registered +//! decomposition (step 5), so an explicit decomposition is never +//! silently bypassed. +//! 5. **Decomposition**: Expand using `GateDefinitions` +//! 6. **Error**: If none of the above apply //! //! Before and after each gate, noise events and user handlers are dispatched. //! @@ -591,6 +596,9 @@ impl GateOverrides { pub enum ExecutionError { /// No decomposition found for a gate. NoDecomposition { gate_id: GateId }, + /// A rotation gate with a non-Clifford angle reached a Clifford-only + /// backend with no rotation support and no registered decomposition. + NonCliffordAngle { gate_id: GateId }, /// Maximum decomposition depth exceeded (possible infinite recursion). MaxDecompositionDepthExceeded, } @@ -601,6 +609,15 @@ impl std::fmt::Display for ExecutionError { Self::NoDecomposition { gate_id } => { write!(f, "No decomposition found for gate ID {}", gate_id.0) } + Self::NonCliffordAngle { gate_id } => { + write!( + f, + "Rotation gate ID {} has a non-Clifford angle, which this Clifford-only \ + backend cannot execute; use a rotation-capable backend such as \ + state_vector()", + gate_id.0 + ) + } Self::MaxDecompositionDepthExceeded => { write!(f, "Maximum decomposition depth exceeded") } @@ -610,6 +627,32 @@ impl std::fmt::Display for ExecutionError { impl std::error::Error for ExecutionError {} +/// Outcome of the Clifford-rotation execution attempt. +#[derive(Clone, Copy, PartialEq, Eq)] +enum CliffordRotationAttempt { + /// The gate executed via Clifford-angle decomposition. + Executed, + /// The gate is a rotation but the angle is not Clifford. + NonCliffordAngle, + /// The gate is not a rotation this path handles. + NotARotation, +} + +/// Upgrade a missing-decomposition error to the more specific +/// non-Clifford-angle error when that is the actual cause. +fn upgrade_rotation_error( + error: ExecutionError, + rotation_attempt: CliffordRotationAttempt, +) -> ExecutionError { + match (error, rotation_attempt) { + ( + ExecutionError::NoDecomposition { gate_id }, + CliffordRotationAttempt::NonCliffordAngle, + ) => ExecutionError::NonCliffordAngle { gate_id }, + (error, _) => error, + } +} + /// Stateless quantum simulation runner. /// /// Applies noise and circuits to a simulator state, producing measurement outcomes. @@ -1201,12 +1244,26 @@ impl CircuitRunner { return Ok(()); } - // Execute through unified precedence chain + // Execute through unified precedence chain. The automatic + // Clifford-angle rotation step is a FALLBACK gated on the + // gate having no user-registered decomposition, so an + // explicit GateDefinitions decomposition wins at Clifford + // angles too (see execute_gate for the rationale). + let mut rotation_attempt = CliffordRotationAttempt::NotARotation; let executed = self.try_execute_override(sim, gate_id, qubits, command.angles.as_slice()) || Self::try_execute_clifford(sim, gate_id, qubits) || self.rotation_executor.is_some_and(|executor| { executor(sim, gate_id, command.angles.as_slice(), qubits) + }) + || (!self.definitions.has_decomposition(gate_id) && { + rotation_attempt = Self::try_execute_clifford_rotation( + sim, + gate_id, + qubits, + command.angles.as_slice(), + ); + rotation_attempt == CliffordRotationAttempt::Executed }); if !executed { @@ -1216,7 +1273,8 @@ impl CircuitRunner { qubits, command.angles.as_slice(), 0, - )?; + ) + .map_err(|e| upgrade_rotation_error(e, rotation_attempt))?; } self.dispatch_after_gate_for_id( @@ -1388,15 +1446,27 @@ impl CircuitRunner { return Ok(()); } - // Try execution in order of precedence + // Try execution in order of precedence. The automatic + // Clifford-angle rotation step is a FALLBACK: it fires only when + // the gate has no user-registered decomposition, so an explicit + // GateDefinitions decomposition wins at Clifford angles too + // (registering a decomposition is an explicit user choice, like + // GateOverrides, and must not be silently bypassed). + let mut rotation_attempt = CliffordRotationAttempt::NotARotation; let executed = self.try_execute_override(sim, gate_id, qubits, angles) || Self::try_execute_clifford(sim, gate_id, qubits) || self .rotation_executor - .is_some_and(|executor| executor(sim, gate_id, angles, qubits)); + .is_some_and(|executor| executor(sim, gate_id, angles, qubits)) + || (!self.definitions.has_decomposition(gate_id) && { + rotation_attempt = + Self::try_execute_clifford_rotation(sim, gate_id, qubits, angles); + rotation_attempt == CliffordRotationAttempt::Executed + }); if !executed { - self.execute_via_decomposition(sim, gate_id, qubits, angles, depth)?; + self.execute_via_decomposition(sim, gate_id, qubits, angles, depth) + .map_err(|e| upgrade_rotation_error(e, rotation_attempt))?; } // Emit after-gate noise event @@ -1529,6 +1599,41 @@ impl CircuitRunner { } } + /// Try to execute a rotation gate with a Clifford angle via the generic + /// `CliffordRotation` decomposition (the same path the pecos-engines + /// stack uses), so Clifford backends run e.g. QASM's `s`/`u1(pi/2)` + /// compiled to `rz(k*pi/2)` without a rotation-capable simulator. + /// Non-Clifford angles fall through to the decomposition registry; + /// the attempt outcome distinguishes them so the final error can point + /// at a rotation-capable backend instead of a missing decomposition. + fn try_execute_clifford_rotation( + sim: &mut S, + gate_id: GateId, + qubits: &[QubitId], + angles: &[Angle64], + ) -> CliffordRotationAttempt { + use pecos_simulators::clifford_rotation::CliffordRotation; + let Some(gate_type) = gate_id.try_to_gate_type() else { + return CliffordRotationAttempt::NotARotation; + }; + let [angle] = angles else { + return CliffordRotationAttempt::NotARotation; + }; + let result = match gate_type { + GateType::RZ => sim.try_rz(*angle, qubits).map(|_| ()), + GateType::RX => sim.try_rx(*angle, qubits).map(|_| ()), + GateType::RY => sim.try_ry(*angle, qubits).map(|_| ()), + GateType::RZZ => sim.try_rzz(*angle, &flat_to_pairs(qubits)).map(|_| ()), + GateType::RXX => sim.try_rxx(*angle, &flat_to_pairs(qubits)).map(|_| ()), + GateType::RYY => sim.try_ryy(*angle, &flat_to_pairs(qubits)).map(|_| ()), + _ => return CliffordRotationAttempt::NotARotation, + }; + match result { + Ok(()) => CliffordRotationAttempt::Executed, + Err(_) => CliffordRotationAttempt::NonCliffordAngle, + } + } + /// Execute a gate via decomposition from `GateDefinitions`. fn execute_via_decomposition( &mut self, @@ -3022,6 +3127,84 @@ mod tests { )); } + #[test] + fn test_non_clifford_angle_gets_specific_error() { + // rz with an arbitrary angle on a Clifford-only backend must point + // the user at a rotation-capable backend, not at a missing + // decomposition. + let circuit = crate::command::CommandBuilder::new() + .pz(&[0]) + .rz(&[0], Angle64::from_radians(0.123)) + .mz(&[0]) + .build(); + + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::new(); + + let err = runner + .apply_circuit(&mut state, &circuit) + .expect_err("non-Clifford angle must error"); + assert!(matches!(err, ExecutionError::NonCliffordAngle { .. })); + assert!(err.to_string().contains("state_vector()")); + } + + #[test] + fn user_registered_decomposition_wins_over_clifford_rotation_at_clifford_angle() { + use crate::extensible::{DecompEntry, DecompOp, Decomposition, GateDefinitions}; + use std::sync::Arc; + + // The automatic Clifford-angle rotation step is a fallback: an + // explicit user-registered decomposition must take precedence even + // at a Clifford angle, instead of being silently bypassed. + // + // Register RZ to "decompose" into a single X gate (not physically + // RZ, but distinguishable): if the decomposition runs, |0> -> |1> + // and the measurement is 1; if the Clifford-rotation fast path runs + // instead, RZ(pi/2) = S leaves |0> unchanged and the measurement + // is 0. + let mut defs = GateDefinitions::new(); + defs.set_decomposition( + gates::RZ, + DecompEntry { + requires: crate::extensible::GateSupportSet::from_iter([gates::X]), + decomposition: Decomposition::Dynamic(Arc::new(vec![DecompOp::gate1(gates::X, 0)])), + }, + ); + + let circuit = CommandBuilder::new() + .pz(&[0]) + .rz(&[0], Angle64::from_radians(std::f64::consts::FRAC_PI_2)) + .mz(&[0]) + .build(); + + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::with_definitions(defs).with_seed(42); + let outcomes = runner.apply_circuit(&mut state, &circuit).unwrap(); + + let bit = outcomes.iter().next().expect("one measurement").outcome; + assert!( + bit, + "the registered RZ->X decomposition must run (measure 1), not the \ + Clifford-rotation S fast path (which would measure 0)" + ); + + // Control: with no registered decomposition, the Clifford-rotation + // fast path executes RZ(pi/2) = S and |0> stays |0> (measure 0). + let control = CommandBuilder::new() + .pz(&[0]) + .rz(&[0], Angle64::from_radians(std::f64::consts::FRAC_PI_2)) + .mz(&[0]) + .build(); + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::new().with_seed(42); + let outcomes = runner.apply_circuit(&mut state, &control).unwrap(); + let bit = outcomes.iter().next().expect("one measurement").outcome; + assert!( + !bit, + "default RZ(pi/2) = S leaves |0> unchanged (measure 0)" + ); + } + // --- apply_gate (interpreter mode) tests --- #[test] diff --git a/exp/pecos-neo/src/sampling/subset.rs b/exp/pecos-neo/src/sampling/subset.rs index fb1dcb531..79e7cb8ab 100644 --- a/exp/pecos-neo/src/sampling/subset.rs +++ b/exp/pecos-neo/src/sampling/subset.rs @@ -13,8 +13,18 @@ //! Subset simulation for estimating very rare event probabilities. //! //! Subset simulation is a multilevel Monte Carlo method that decomposes a rare event -//! into a sequence of more frequent intermediate events. This allows efficient estimation -//! of probabilities as low as 1e-10 or smaller. +//! into a sequence of more frequent intermediate events, reducing the sample count +//! needed compared to direct Monte Carlo. +//! +//! ## Accuracy caveat +//! +//! The multi-level resampling in this implementation does not yet condition the +//! resampled population correctly across levels, which biases estimates upward and +//! degrades confidence-interval coverage once more than one level engages (measured: +//! ~+21% bias and ~15% CI coverage at event probabilities near 1e-3). Treat results +//! that engaged multiple levels as approximate until the estimator overhaul lands; +//! single-level runs (event probability within reach of `samples_per_level`) behave +//! like direct Monte Carlo and are unbiased. //! //! ## Algorithm //! @@ -366,16 +376,21 @@ where num_failures, }); - cumulative_prob *= conditional_prob; - - // Check termination conditions + // Check termination conditions BEFORE committing this level's + // conditional probability: the final estimate is + // cumulative_prob x (failure fraction of the current population), + // so a level's conditional must only be multiplied in when the + // population is actually conditioned on its threshold (resampled + // from the survivors). Otherwise the threshold-crossing + // probability gets counted twice. if num_failures == samples.len() { - // All samples have failed - we're done + // All samples have failed - we're done (final fraction = 1). break; } if conditional_prob < self.config.min_conditional_prob { - // Probability too small to continue reliably + // Probability too small to continue reliably; estimate from + // the current (unconditioned) population. break; } @@ -385,11 +400,19 @@ where break; } - // Check if all survivors are failures - let survivors: Vec<_> = samples.iter().filter(|s| s.score >= threshold).collect(); + // Commit this level: condition on score >= threshold. + cumulative_prob *= conditional_prob; - if survivors.iter().all(|s| s.is_failure) { - // All survivors are failures - we're done + // Check if all survivors are failures + if samples + .iter() + .filter(|s| s.score >= threshold) + .all(|s| s.is_failure) + { + // All survivors are failures - we're done. Keep only the + // conditioned population so the final failure fraction is + // measured on it (= 1.0 here). + samples.retain(|s| s.score >= threshold); break; } @@ -484,15 +507,18 @@ where /// Run one sample and return (`outcomes`, `score`, `is_failure`). fn run_one_sample(&self, rng: &mut PecosRng) -> (MeasurementOutcomes, f64, bool) { let mut sim = SparseStab::new(self.num_qubits); - let mut runner = CircuitRunner::::new().with_rng(rng.clone()); + let mut runner = CircuitRunner::::new(); // Get fresh noise model from builder if let Some(noise) = (self.noise_builder)() { runner = runner.with_noise(noise); } - // Advance the RNG so next call gets different randomness - rng.random::(); + // Seed both the noise RNG and the simulator's internal measurement + // RNG from the master stream; seeding only the noise RNG leaves + // measurement randomness nondeterministic. + let sample_seed: u64 = rng.random(); + runner.set_full_seed(&mut sim, sample_seed); let outcomes = runner .apply_circuit(&mut sim, &self.circuit) @@ -666,6 +692,39 @@ impl BernoulliSubsetSimulation { prob } } +/// Squared coefficient of variation for a subset-simulation estimate under +/// the standard independent-levels approximation (Au & Beck 2001). +/// +/// The estimate is `P(F) = (prod_i p_i) * q`, where `p_i` are the committed +/// intermediate-level conditionals and `q = P(F | E_k)` is the final +/// failure fraction. Each factor estimated from `n` samples contributes a +/// relative variance `(1 - p) / (n * p)`; the total CV^2 is their sum. The +/// final-fraction term `(1 - q) / (n * q)` MUST be included — omitting it +/// (as the earlier code did) reports CIs that are too tight. +/// +/// This is the INDEPENDENT-levels estimate: it assumes the per-level +/// estimators are uncorrelated. The resampling that conditions each level +/// on the previous induces positive inter-level correlation, so this is a +/// LOWER bound on the true CV. The correlation-corrected Au-Beck estimator +/// scales each term by `(1 + gamma_i)` with `gamma_i` the level's chain +/// autocorrelation, which is not estimated here. Callers reporting this CV +/// should treat it as an optimistic (lower) bound, not an exact CV. +#[allow(clippy::cast_precision_loss)] // sample counts are far below f64 precision +fn independent_levels_cv_squared( + level_conditionals: impl Iterator, + final_fraction: f64, + n: usize, +) -> f64 { + let relative_variance = |p: f64| { + if p > 0.0 && p < 1.0 { + (1.0 - p) / (n as f64 * p) + } else { + 0.0 + } + }; + level_conditionals.map(relative_variance).sum::() + relative_variance(final_fraction) +} + /// Binomial probability mass function: C(n,k) * p^k * (1-p)^(n-k) #[allow(clippy::cast_precision_loss)] // mathematical calculation fn binomial_pmf(n: usize, k: usize, p: f64) -> f64 { @@ -1205,6 +1264,9 @@ impl ProperSubsetSimulation { // Step 2: Iteratively apply subset simulation levels let mut current_threshold = 0.0; let mut cumulative_prob = 1.0; + // Conditionals actually committed into the estimate (terminal + // levels are recorded but not multiplied in); CV uses these. + let mut committed_conditionals: Vec = Vec::new(); for level in 0..self.config.max_levels { // Sort trajectories by final score (descending) @@ -1214,10 +1276,14 @@ impl ProperSubsetSimulation { .unwrap_or(std::cmp::Ordering::Equal) }); - // Find adaptive threshold: score at (1-p0) quantile + // Find the adaptive threshold so the top p0 fraction survives. + // Trajectories are sorted DESCENDING (highest score first), so + // the survivor threshold is the score at index floor(p0 * n) — + // index (1 - p0) * n would pick the BOTTOM fraction and invert + // the conditioning (~1-p0 survive instead of ~p0). #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - // p0 in [0,1] so (1-p0)*n fits in usize - let threshold_idx = ((1.0 - p0) * n as f64).floor() as usize; + // p0 in [0,1] so p0*n fits in usize + let threshold_idx = (p0 * n as f64).floor() as usize; let threshold_idx = threshold_idx.min(n - 1); let new_threshold = self.trajectories[threshold_idx].score; @@ -1235,7 +1301,6 @@ impl ProperSubsetSimulation { // Conditional probability for this level let conditional_prob = num_above as f64 / n as f64; - cumulative_prob *= conditional_prob; // Record level statistics let num_failures = self.trajectories.iter().filter(|t| t.is_failure).count(); @@ -1248,16 +1313,28 @@ impl ProperSubsetSimulation { num_failures, }); - // If all trajectories have failed, we're done + // Terminal checks BEFORE committing this level's conditional: + // the final estimate is cumulative_prob x (failure fraction of + // the current population), so a level's conditional must only be + // multiplied in once the population is resampled (conditioned) + // on its threshold. Otherwise the threshold crossing is counted + // twice. + + // If all trajectories have failed, we're done (fraction = 1). if num_failures == n { break; } - // If threshold exceeds failure threshold, we're done + // If the threshold reaches the failure threshold, stop and let + // the final failure fraction condition directly on the failure + // event over the current population. if new_threshold >= self.failure_threshold { break; } + cumulative_prob *= conditional_prob; + committed_conditionals.push(conditional_prob); + current_threshold = new_threshold; // Step 3: Resample - replace trajectories below threshold @@ -1339,18 +1416,12 @@ impl ProperSubsetSimulation { // The probability estimate let probability = cumulative_prob * final_failure_fraction; - // Coefficient of variation estimate - let cv_squared: f64 = self - .levels - .iter() - .map(|l| { - if l.conditional_prob > 0.0 && l.conditional_prob < 1.0 { - (1.0 - l.conditional_prob) / (n as f64 * l.conditional_prob) - } else { - 0.0 - } - }) - .sum(); + // Independent-levels CV, including the final failure-fraction term. + let cv_squared = independent_levels_cv_squared( + committed_conditionals.iter().copied(), + final_failure_fraction, + n, + ); SubsetResult { levels: self.levels, @@ -1430,6 +1501,9 @@ impl ProperSubsetSimulation { // Step 4: Iteratively apply subset simulation at each threshold let mut current_threshold = 0.0; let mut cumulative_prob = 1.0; + // Conditionals actually committed into the estimate (terminal + // levels are recorded but not multiplied in); CV uses these. + let mut committed_conditionals: Vec = Vec::new(); for (level, &target_threshold) in thresholds.iter().enumerate() { if level >= self.config.max_levels { @@ -1452,10 +1526,11 @@ impl ProperSubsetSimulation { // If no trajectories exceed this threshold, use quantile-based threshold let actual_threshold = if num_above == 0 { - // Use the (1-p0) quantile as the new threshold + // Top p0 fraction survives: descending sort, so index + // floor(p0 * n) (index (1-p0)*n would pick the bottom). #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - // p0 in [0,1] so (1-p0)*n fits in usize - let threshold_idx = ((1.0 - p0) * n as f64).floor() as usize; + // p0 in [0,1] so p0*n fits in usize + let threshold_idx = (p0 * n as f64).floor() as usize; let threshold_idx = threshold_idx.min(n - 1); self.trajectories[threshold_idx].score } else { @@ -1477,10 +1552,6 @@ impl ProperSubsetSimulation { // Conditional probability for this level let conditional_prob = num_above as f64 / n as f64; - if conditional_prob > 0.0 { - cumulative_prob *= conditional_prob; - } - // Record level statistics let num_failures = self.trajectories.iter().filter(|t| t.is_failure).count(); self.levels.push(LevelStats { @@ -1492,16 +1563,29 @@ impl ProperSubsetSimulation { num_failures, }); - // If all trajectories have failed, we're done + // Terminal checks BEFORE committing this level's conditional: + // the final estimate is cumulative_prob x (failure fraction of + // the current population), so a level's conditional must only be + // multiplied in once the population is resampled (conditioned) + // on its threshold. Otherwise the threshold crossing is counted + // twice. + + // If all trajectories have failed, we're done (fraction = 1). if num_failures == n { break; } - // If threshold reached failure level, we're done + // If the threshold reaches the failure level, stop and let the + // final failure fraction condition directly on the failure event. if actual_threshold >= self.failure_threshold { break; } + if conditional_prob > 0.0 { + cumulative_prob *= conditional_prob; + committed_conditionals.push(conditional_prob); + } + current_threshold = actual_threshold; // Step 5: Resample - replace trajectories below threshold @@ -1583,18 +1667,12 @@ impl ProperSubsetSimulation { // The probability estimate let probability = cumulative_prob * final_failure_fraction; - // Coefficient of variation estimate - let cv_squared: f64 = self - .levels - .iter() - .map(|l| { - if l.conditional_prob > 0.0 && l.conditional_prob < 1.0 { - (1.0 - l.conditional_prob) / (n as f64 * l.conditional_prob) - } else { - 0.0 - } - }) - .sum(); + // Independent-levels CV, including the final failure-fraction term. + let cv_squared = independent_levels_cv_squared( + committed_conditionals.iter().copied(), + final_failure_fraction, + n, + ); SubsetResult { levels: self.levels, @@ -2013,6 +2091,9 @@ impl QecSubsetSimulation { let mut current_threshold = 0.0; let mut cumulative_prob = 1.0; let mut levels = Vec::new(); + // Conditionals actually committed into the estimate (terminal + // levels are recorded but not multiplied in); CV uses these. + let mut committed_conditionals: Vec = Vec::new(); for level in 0..self.config.base.max_levels { // Sort by final score (descending) @@ -2024,10 +2105,12 @@ impl QecSubsetSimulation { .unwrap_or(std::cmp::Ordering::Equal) }); - // Find adaptive threshold: score at (1-p0) quantile + // Top p0 fraction survives: histories sorted DESCENDING, so the + // survivor threshold is at index floor(p0 * n); index (1-p0)*n + // would pick the BOTTOM fraction and invert the conditioning. #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - // p0 in [0,1] so (1-p0)*n fits in usize - let threshold_idx = ((1.0 - p0) * n as f64).floor() as usize; + // p0 in [0,1] so p0*n fits in usize + let threshold_idx = (p0 * n as f64).floor() as usize; let threshold_idx = threshold_idx.min(n - 1); let new_threshold = histories[indices[threshold_idx]].final_score; @@ -2044,7 +2127,6 @@ impl QecSubsetSimulation { // Conditional probability for this level let conditional_prob = num_above as f64 / n as f64; - cumulative_prob *= conditional_prob; // Record level statistics let num_failures = histories.iter().filter(|h| h.is_failure).count(); @@ -2057,16 +2139,28 @@ impl QecSubsetSimulation { num_failures, }); - // If all trajectories have failed, we're done + // Terminal checks BEFORE committing this level's conditional: + // the final estimate is cumulative_prob x (failure fraction of + // the current population), so a level's conditional must only be + // multiplied in once the population is resampled (conditioned) + // on its threshold. Otherwise the threshold crossing is counted + // twice. + + // If all trajectories have failed, we're done (fraction = 1). if num_failures == n { break; } - // If threshold exceeds failure threshold, we're done + // If the threshold reaches the failure threshold, stop and let + // the final failure fraction condition directly on the failure + // event over the current population. if new_threshold >= self.config.failure_threshold { break; } + cumulative_prob *= conditional_prob; + committed_conditionals.push(conditional_prob); + current_threshold = new_threshold; // Step 3: Resample - replace trajectories below threshold @@ -2154,17 +2248,12 @@ impl QecSubsetSimulation { traj.is_failure = hist.is_failure; } - // Coefficient of variation estimate - let cv_squared: f64 = levels - .iter() - .map(|l| { - if l.conditional_prob > 0.0 && l.conditional_prob < 1.0 { - (1.0 - l.conditional_prob) / (n as f64 * l.conditional_prob) - } else { - 0.0 - } - }) - .sum(); + // Independent-levels CV, including the final failure-fraction term. + let cv_squared = independent_levels_cv_squared( + committed_conditionals.iter().copied(), + final_failure_fraction, + n, + ); self.levels = levels .iter() @@ -2399,6 +2488,264 @@ mod tests { // estimate probabilities that direct MC would need many more samples for } + #[test] + fn proper_subset_threshold_keeps_top_fraction_not_bottom() { + // Directly pins the threshold-selection fix. Trajectories are sorted + // DESCENDING, so the survivor threshold must be the score at index + // floor(p0 * n) (keep the TOP p0). The pre-fix code indexed + // (1 - p0) * n, which kept the BOTTOM fraction and made each level's + // measured conditional ~ (1 - p0) instead of ~ p0. + // + // This is the discriminating assertion: with the fix the first + // level's conditional is near threshold_fraction (~0.2); with the + // inverted index it is near 1 - threshold_fraction (~0.8). The + // analytical-match check below does NOT catch this on its own (the + // estimate telescopes to the right value either way because the + // conditioning is correct — the quantile only controls how + // aggressively each level cuts), which is exactly why this explicit + // conditional check is needed. + let config = SubsetConfig::new() + .with_samples_per_level(3000) + .with_threshold_fraction(0.2) + .with_max_levels(20) + .with_seed(42); + let result = ProperSubsetSimulation::new(0.05, 1.0, 15.0, 100, config).run(); + + let first = result + .levels + .first() + .expect("at least one level") + .conditional_prob; + assert!( + first < 0.5, + "first-level conditional {first:.3} must be near threshold_fraction (0.2), \ + not its complement (0.8) — the threshold must keep the top fraction" + ); + } + + #[test] + fn proper_subset_simulation_matches_analytical() { + // Unbiasedness regression guard for ProperSubsetSimulation against + // the EXACT analytical probability of the same Bernoulli damage + // process. This guard was MISSING entirely: the existing analytical + // tests only exercised BernoulliSubsetSimulation (direct MC), never + // the multi-level Au-Beck path. It validates that the multi-level + // estimator is unbiased (it is, because the checkpoint resample + // conditions correctly); the inverted-quantile defect is pinned + // separately by proper_subset_threshold_keeps_top_fraction_not_bottom. + let p_damage = 0.05; + let increment = 1.0; + let num_rounds = 100; + // P(Binom(100, 0.05) >= 15), mean 5: well below 1/samples, so direct + // MC over samples_per_level would essentially never reach it; only + // multi-level conditioning estimates it stably. + let failure_threshold = 15.0; + let samples_per_level = 3000; + + let analytical = BernoulliSubsetSimulation::new(p_damage, num_rounds, failure_threshold) + .analytical_probability(); + assert!( + analytical > 0.0 && analytical < 1.0 / samples_per_level as f64, + "test setup: analytical {analytical:.2e} must be nonzero AND below 1/samples", + ); + + let config = SubsetConfig::new() + .with_samples_per_level(samples_per_level) + .with_threshold_fraction(0.2) + .with_max_levels(20) + .with_seed(42); + let estimate = + ProperSubsetSimulation::new(p_damage, increment, failure_threshold, num_rounds, config) + .run() + .probability(); + + let ratio = (estimate / analytical).max(analytical / estimate); + assert!( + estimate > 0.0 && ratio < 3.0, + "ProperSubsetSimulation estimate {estimate:.3e} should be within 3x of \ + analytical {analytical:.3e} (ratio {ratio:.2})" + ); + } + + #[test] + #[allow(clippy::cast_precision_loss)] + fn proper_subset_reported_cv_tracks_the_empirical_spread() { + // Validate the reported coefficient of variation (now including the + // final failure-fraction term) against the EMPIRICAL CV measured + // across many independent runs. The reported CV is the + // independent-levels estimate, so it is a LOWER bound on the true + // spread (resampling correlation inflates the real variance). For + // this regime the empirical CV (~0.155) is ~1.8x the reported CV + // (~0.088), quantifying that correlation gap — which is exactly why + // the doc on `independent_levels_cv_squared` calls it an optimistic + // bound. The test requires the reported CV to be a right-order lower + // bound, and that the final-fraction term is genuinely included + // (dropping it, as the pre-fix code did, gives a strictly smaller + // CV). + let (p_damage, increment, num_rounds, failure_threshold) = (0.1, 1.0, 40, 11.0); + let n = 2000; + let k_runs = 200usize; + + let mut estimates = Vec::with_capacity(k_runs); + let mut reported_cv = Vec::with_capacity(k_runs); + let mut cv_sq_dropping_final = Vec::with_capacity(k_runs); + + for seed in 0..k_runs { + let config = SubsetConfig::new() + .with_samples_per_level(n) + .with_threshold_fraction(0.2) + .with_max_levels(20) + .with_seed(seed as u64 + 1); + let result = ProperSubsetSimulation::new( + p_damage, + increment, + failure_threshold, + num_rounds, + config, + ) + .run(); + estimates.push(result.probability); + reported_cv.push(result.coefficient_of_variation); + // Reconstruct the COMMITTED-intermediate CV (no final term) for + // the materiality check. Committed levels are those that did not + // trigger a terminal break (all-failed or threshold-reaches- + // failure); a terminal level is recorded in `levels` but its + // conditional is never a factor in the estimate, so it must be + // excluded here exactly as the production CV excludes it. + let nf = n as f64; + let intermediate: f64 = result + .levels + .iter() + .filter(|l| l.num_failures != n && l.threshold < failure_threshold) + .map(|l| { + let p = l.conditional_prob; + if p > 0.0 && p < 1.0 { + (1.0 - p) / (nf * p) + } else { + 0.0 + } + }) + .sum(); + cv_sq_dropping_final.push(intermediate); + } + + let mean_est = estimates.iter().sum::() / k_runs as f64; + let var_est = estimates + .iter() + .map(|e| (e - mean_est).powi(2)) + .sum::() + / (k_runs as f64 - 1.0); + let empirical_cv = var_est.sqrt() / mean_est; + let mean_reported_cv = reported_cv.iter().sum::() / k_runs as f64; + let mean_cv_dropping_final = + (cv_sq_dropping_final.iter().sum::() / k_runs as f64).sqrt(); + + println!( + "CV check: empirical={empirical_cv:.3}, reported={mean_reported_cv:.3}, \ + dropping_final_term={mean_cv_dropping_final:.3}" + ); + + // The reported CV is a right-order LOWER bound on the empirical + // spread: it should not substantially exceed empirical (it is a + // lower bound; allow 30% for finite-sample noise in the per-run + // conditionals) and should be within ~2.5x below it (it captures the + // right order, not a token number). + assert!( + mean_reported_cv < empirical_cv * 1.3, + "reported CV {mean_reported_cv:.3} should not exceed empirical {empirical_cv:.3} \ + by much (it is an independent-levels lower bound)" + ); + assert!( + mean_reported_cv > empirical_cv * 0.4, + "reported CV {mean_reported_cv:.3} should be the right order vs empirical \ + {empirical_cv:.3}, not a token value" + ); + // The final failure-fraction term is genuinely included: dropping it + // (the pre-fix behavior) gives a strictly smaller CV. + assert!( + mean_reported_cv > mean_cv_dropping_final, + "the final failure-fraction term must be included (reported {mean_reported_cv:.3} \ + must exceed the intermediate-levels-only {mean_cv_dropping_final:.3})" + ); + } + + #[test] + #[allow(clippy::cast_precision_loss)] + fn proper_subset_cv_counts_committed_factors_not_recorded_levels() { + // Discriminating regression for the CV double-count: a terminal level + // — one whose new threshold reaches the failure level — is RECORDED in + // `levels` for reporting, but its conditional is never multiplied into + // the estimator. The CV must be a function of the committed product + // factors only; including a terminal conditional double-counts the + // boundary (the terminal conditional is itself a measure of the + // failure tail, the same quantity the final failure fraction already + // captures). This reconstructs both the committed-only and the + // all-recorded-levels CV^2 from the result and asserts the reported + // CV uses the former. On the pre-fix code (CV over all recorded + // levels) the first assertion fails, so this test discriminates. + let (p_damage, increment, num_rounds, failure_threshold) = (0.1, 1.0, 40, 11.0); + let n = 2000; + let config = SubsetConfig::new() + .with_samples_per_level(n) + .with_threshold_fraction(0.2) + .with_max_levels(20) + .with_seed(1); + let result = + ProperSubsetSimulation::new(p_damage, increment, failure_threshold, num_rounds, config) + .run(); + + // The committed factors are exactly the non-terminal recorded levels. + let committed: Vec = result + .levels + .iter() + .filter(|l| l.num_failures != n && l.threshold < failure_threshold) + .map(|l| l.conditional_prob) + .collect(); + // A terminal level must actually be present, or the test proves nothing. + assert!( + result.levels.len() > committed.len(), + "test regime must produce a terminal recorded-but-uncommitted level \ + (recorded {}, committed {})", + result.levels.len(), + committed.len() + ); + + // The estimate is prod(committed) * q, so recover the final failure + // fraction q = probability / prod(committed) to rebuild the CV terms. + let nf = n as f64; + let prod_committed: f64 = committed.iter().product(); + let q = result.probability / prod_committed; + let term = |p: f64| { + if p > 0.0 && p < 1.0 { + (1.0 - p) / (nf * p) + } else { + 0.0 + } + }; + let committed_cv_sq: f64 = committed.iter().map(|&p| term(p)).sum::() + term(q); + let all_levels_cv_sq: f64 = result + .levels + .iter() + .map(|l| term(l.conditional_prob)) + .sum::() + + term(q); + let reported_cv_sq = result.coefficient_of_variation.powi(2); + + // The reported CV is built from committed factors only... + assert!( + (reported_cv_sq - committed_cv_sq).abs() <= 1e-9 * committed_cv_sq.max(1e-12), + "reported CV^2 {reported_cv_sq:.6e} must equal the committed-only formula \ + {committed_cv_sq:.6e}, not the all-recorded-levels formula {all_levels_cv_sq:.6e}" + ); + // ...and the all-recorded-levels formula (the pre-fix behavior) is a + // genuinely different, larger value, so the check above is not vacuous. + assert!( + all_levels_cv_sq > committed_cv_sq + 1e-9, + "all-recorded-levels CV^2 {all_levels_cv_sq:.6e} must exceed the committed-only \ + CV^2 {committed_cv_sq:.6e}: the terminal level must double-count the boundary" + ); + } + // ======================================================================== // ECS-Based Subset Simulation Tests // ======================================================================== diff --git a/exp/pecos-neo/src/tool.rs b/exp/pecos-neo/src/tool.rs index 7393aa270..fcce2ecc1 100644 --- a/exp/pecos-neo/src/tool.rs +++ b/exp/pecos-neo/src/tool.rs @@ -89,18 +89,22 @@ mod system; // Re-export core types pub use self::core::Tool; +pub use crate::sampling::subset::{LevelStats, SubsetResult}; pub use importance::{ CurrentShotWeight, ImportanceSamplingConfig, ImportanceSamplingPlugin, ImportanceSamplingResults, }; +pub use pecos_results::{Data, Shot, ShotMap, ShotVec}; pub use plugin::{Plugin, PluginGroup}; pub use resource::{Resource, Resources}; pub use simulation::{ - Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, NoiseResource, QuantumBackend, - Sampling, SimConfig, SimNeoBuilder, SimNeoInput, Simulation, SimulationResults, - SimulatorFactory, SparseStabBuilder, StabilizerBuilder, StateVecBuilder, StoredOverrides, + Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, MonteCarloBuilder, NoiseResource, + PathEnumerationBuilder, QuantumBackend, Sampling, SimConfig, SimNeoBuilder, SimNeoInput, + Simulation, SimulationResults, SimulatorFactory, SparseStabBuilder, StabilizerBuilder, + StateVecBuilder, StoredOverrides, SubsetFailureFn, SubsetScoreFn, SubsetSimulationBuilder, custom_backend, custom_backend_from_factory, custom_backend_with_rotations, - importance_sampling, sim_neo, sim_neo_builder, sparse_stab, stabilizer, state_vector, + importance_sampling, monte_carlo, path_enumeration, sim_neo, sim_neo_builder, sparse_stab, + stabilizer, state_vector, subset_simulation, }; pub use simulation::{PendingEngineBuilder, TypedProgram}; pub use system::{IntoSystem, Schedule, System}; diff --git a/exp/pecos-neo/src/tool/design.md b/exp/pecos-neo/src/tool/design.md index 7ccdcd3f4..0126c7985 100644 --- a/exp/pecos-neo/src/tool/design.md +++ b/exp/pecos-neo/src/tool/design.md @@ -41,7 +41,7 @@ builder-of-builders pattern: sim_neo(circuit) .classical(qasm_engine()) .quantum(stabilizer()) - .sampling(importance_sampling()) + .sampling(importance_sampling(10_000)) .build() .run(); ``` @@ -186,8 +186,9 @@ Convenience function that creates a simulation-configured builder: /// # Example /// ``` /// let results = sim_neo(circuit) +/// .auto() /// .noise(SingleQubitChannel::depolarizing(0.01)) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -205,22 +206,20 @@ Builder that configures a Tool for simulation: pub struct SimNeoBuilder { circuit: CommandQueue, noise: Option, - shots: usize, seed: Option, - workers: usize, - importance_sampling: Option, + sampling: Option, // monte_carlo(shots).workers(n) | importance_sampling(shots) } impl SimNeoBuilder { pub fn new(circuit: CommandQueue) -> Self { ... } // Configuration (consumes self, returns Self) - pub fn shots(mut self, shots: usize) -> Self { ... } pub fn seed(mut self, seed: u64) -> Self { ... } - pub fn workers(mut self, workers: usize) -> Self { ... } - pub fn auto_workers(mut self) -> Self { ... } pub fn noise(mut self, noise: impl Into) -> Self { ... } - pub fn importance_sampling(mut self, base_rate: f64, boost: f64) -> Self { ... } + // Sampling strategy carries its own shots/workers: + // .sampling(monte_carlo(1000).workers(8)) + // .sampling(importance_sampling(10_000).with_boost(10.0)) + pub fn sampling(mut self, sampling: impl Into) -> Self { ... } /// Build the simulation handle pub fn build(self) -> Simulation { @@ -271,17 +270,17 @@ impl Simulation { ```rust // Pattern 1: One-shot (builder consumed) -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .noise(depolarizing(0.01)) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .build() .run(); // Pattern 2: Build once, run many -let mut sim = sim_neo(circuit) +let mut sim = sim_neo(circuit).auto() .noise(depolarizing(0.01)) - .shots(1000) + .sampling(monte_carlo(1000)) .build(); let results1 = sim.run(); diff --git a/exp/pecos-neo/src/tool/importance.rs b/exp/pecos-neo/src/tool/importance.rs index faa154040..53d836c1e 100644 --- a/exp/pecos-neo/src/tool/importance.rs +++ b/exp/pecos-neo/src/tool/importance.rs @@ -25,7 +25,7 @@ //! let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); //! //! // Create simulation with importance sampling -//! let mut sim = sim_neo(circuit) +//! let mut sim = sim_neo(circuit).auto() //! .depolarizing(0.001) // True error rate //! .build(); //! diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 022e66aae..01bcbb8a5 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -28,16 +28,16 @@ //! For circuits without mid-circuit classical control: //! //! ```no_run -//! use pecos_neo::tool::sim_neo; +//! use pecos_neo::tool::{monte_carlo, sim_neo}; //! use pecos_neo::prelude::*; //! //! let circuit = CommandBuilder::new() //! .pz(&[0]).h(&[0]).mz(&[0]) //! .build(); //! -//! let results = sim_neo(circuit) +//! let results = sim_neo(circuit).auto() //! .depolarizing(0.01) -//! .shots(1000) +//! .sampling(monte_carlo(1000)) //! .seed(42) //! .build() //! .run(); @@ -48,7 +48,7 @@ //! For QASM programs with classical control flow: //! //! ```no_run -//! use pecos_neo::tool::sim_neo; +//! use pecos_neo::tool::{monte_carlo, sim_neo}; //! use pecos_qasm::qasm_engine; //! //! let qasm = r#" @@ -63,10 +63,10 @@ //! "#; //! //! // Pass QASM source, then set the engine -//! let results = sim_neo(qasm) +//! let results = sim_neo(qasm).auto() //! .classical(qasm_engine()) //! .depolarizing(0.01) -//! .shots(1000) +//! .sampling(monte_carlo(1000)) //! .seed(42) //! .build() //! .run(); @@ -77,19 +77,19 @@ //! Any `ClassicalControlEngineBuilder` works with `sim_neo()`: //! //! ```text -//! use pecos_neo::tool::sim_neo; +//! use pecos_neo::tool::{monte_carlo, sim_neo}; //! use pecos_hugr::hugr_engine; //! use pecos_qis::qis_engine; //! //! // HUGR programs -//! let results = sim_neo(hugr_engine().hugr(&hugr_module)) -//! .shots(1000) +//! let results = sim_neo(hugr_engine().hugr(&hugr_module)).auto() +//! .sampling(monte_carlo(1000)) //! .build() //! .run(); //! //! // QIS programs -//! let results = sim_neo(qis_engine().qis(&qis_program)) -//! .shots(1000) +//! let results = sim_neo(qis_engine().qis(&qis_program)).auto() +//! .sampling(monte_carlo(1000)) //! .build() //! .run(); //! ``` @@ -99,12 +99,12 @@ //! Build once, run multiple times: //! //! ```no_run -//! use pecos_neo::tool::sim_neo; +//! use pecos_neo::tool::{monte_carlo, sim_neo}; //! use pecos_neo::prelude::*; //! //! let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -//! let mut sim = sim_neo(circuit) -//! .shots(1000) +//! let mut sim = sim_neo(circuit).auto() +//! .sampling(monte_carlo(1000)) //! .build(); //! //! let results1 = sim.run(); @@ -119,6 +119,8 @@ use crate::outcome::{MeasurementOutcomes, RegisterMap}; use crate::program::{CommandSource, DynProgramRunner, ProgramRunner, StaticProgram}; use crate::runner::{EventHandlers, GateOverrides}; use crate::sampling::importance_runner::ImportanceSamplingRunner; +use crate::sampling::path::{PathEnumerator, PathExplorer}; +use crate::sampling::subset::{SubsetConfig, SubsetResult, SubsetSimulation}; use pecos_core::rng::RngManageable; use pecos_core::rng::rng_manageable::derive_seed; use pecos_random::PecosRng; @@ -127,6 +129,7 @@ use pecos_simulators::{ }; use rayon::prelude::*; use std::collections::BTreeMap; +use std::sync::Arc; use super::resource::Resources; use super::{Plugin, Stage, Tool}; @@ -137,13 +140,17 @@ use super::{Plugin, Stage, Tool}; /// /// This enum represents the choice of quantum simulator. The actual simulator /// is constructed at build time, following the builder-of-builders pattern. -#[derive(Default)] +/// +/// There is no default: select a backend explicitly via +/// [`SimNeoBuilder::quantum()`](SimNeoBuilder::quantum), or call +/// [`SimNeoBuilder::auto()`](SimNeoBuilder::auto) to opt into automatic +/// selection (currently `SparseStab`). pub enum QuantumBackend { - /// Sparse stabilizer simulator (default). + /// Sparse stabilizer simulator. /// /// Efficient for Clifford circuits and QEC simulations. /// Only supports Clifford gates (H, S, CNOT, CZ, etc.). - #[default] + /// This is what `.auto()` selects. SparseStab, /// Public stabilizer simulator. @@ -167,11 +174,12 @@ pub enum QuantumBackend { /// Custom simulator backend via factory function. /// /// Allows any simulator implementing `CliffordGateable + RngManageable` - /// to be used through the `sim_neo()` API. Use [`custom_backend()`] to create. + /// to be used through the `sim_neo().auto()` API. Use [`custom_backend()`] to create. /// - /// Custom backends only support sequential execution. Using `.workers()`, - /// `.auto_workers()`, or importance sampling will panic at `.run()` time. - Custom(Box), + /// The factory is invoked once per worker, so parallel Monte Carlo + /// works like the built-in backends (per-shot seeding from global shot + /// indices keeps results identical for any worker count). + Custom(Arc), } impl std::fmt::Debug for QuantumBackend { @@ -250,19 +258,19 @@ impl From for QuantumBackend { /// Create a sparse stabilizer backend builder. /// -/// The sparse stabilizer is the default backend, efficient for Clifford circuits +/// The sparse stabilizer is the backend `.auto()` selects, efficient for Clifford circuits /// and quantum error correction simulations. /// /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, sparse_stab}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, sparse_stab}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// let results = sim_neo(circuit) /// .quantum(sparse_stab()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -280,13 +288,13 @@ pub fn sparse_stab() -> SparseStabBuilder { /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, stabilizer}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, stabilizer}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// let results = sim_neo(circuit) /// .quantum(stabilizer()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -303,13 +311,13 @@ pub fn stabilizer() -> StabilizerBuilder { /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, state_vector}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, state_vector}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// let results = sim_neo(circuit) /// .quantum(state_vector()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -438,7 +446,7 @@ where /// /// Created via [`custom_backend()`]. Converts into [`QuantumBackend::Custom`]. pub struct CustomBackendBuilder { - factory: Box, + factory: Arc, } impl From for QuantumBackend { @@ -450,17 +458,18 @@ impl From for QuantumBackend { /// Create a custom backend from a factory closure. /// /// This allows any simulator implementing `CliffordGateable + RngManageable` -/// to be used through `sim_neo()`. The closure receives the number of qubits +/// to be used through `sim_neo().auto()`. The closure receives the number of qubits /// and should return a new simulator instance. /// -/// **Note:** Custom backends only support sequential execution. Using `.workers()`, -/// `.auto_workers()`, or importance sampling with a custom backend will panic -/// at `.run()` time. +/// The factory is invoked once per worker for parallel Monte Carlo, so +/// `.workers(n)` works like the built-in backends. (Importance sampling +/// always runs on its internal sparse stabilizer and ignores the backend +/// choice.) /// /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, custom_backend}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, custom_backend}; /// use pecos_neo::prelude::*; /// use pecos_simulators::SparseStab; /// @@ -469,7 +478,7 @@ impl From for QuantumBackend { /// // Use a custom simulator backend /// let results = sim_neo(circuit) /// .quantum(custom_backend(|n| SparseStab::new(n))) -/// .shots(100) +/// .sampling(monte_carlo(100)) /// .seed(42) /// .build() /// .run(); @@ -481,7 +490,7 @@ where F: Fn(usize) -> S + Send + Sync + 'static, { CustomBackendBuilder { - factory: Box::new(factory), + factory: Arc::new(factory), } } @@ -495,7 +504,7 @@ pub fn custom_backend_from_factory( factory: impl SimulatorFactory + 'static, ) -> CustomBackendBuilder { CustomBackendBuilder { - factory: Box::new(factory), + factory: Arc::new(factory), } } @@ -508,7 +517,7 @@ pub fn custom_backend_from_factory( /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, custom_backend_with_rotations}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, custom_backend_with_rotations}; /// use pecos_neo::prelude::*; /// use pecos_simulators::StateVec; /// @@ -516,7 +525,7 @@ pub fn custom_backend_from_factory( /// /// let results = sim_neo(circuit) /// .quantum(custom_backend_with_rotations(|n| StateVec::new(n))) -/// .shots(100) +/// .sampling(monte_carlo(100)) /// .seed(42) /// .build() /// .run(); @@ -533,7 +542,7 @@ where F: Fn(usize) -> S + Send + Sync + 'static, { CustomBackendBuilder { - factory: Box::new(RotationSimulatorFactory(factory)), + factory: Arc::new(RotationSimulatorFactory(factory)), } } @@ -644,13 +653,13 @@ impl SimNeoInput for &pecos_quantum::DagCircuit { /// When passing a string, use `.classical(engine)` to specify how to interpret it: /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; -/// sim_neo(qasm_code) +/// sim_neo(qasm_code).auto() /// .classical(qasm_engine()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -673,7 +682,7 @@ impl SimNeoInput for String { /// `.classical(engine)` for explicit control: /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_programs::Qasm; /// use pecos_qasm::qasm_engine; /// @@ -682,14 +691,14 @@ impl SimNeoInput for String { /// // Auto mode - uses qasm_engine() automatically /// sim_neo(Qasm::from_string(qasm_code.clone())) /// .auto() -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// /// // Explicit mode -/// sim_neo(Qasm::from_string(qasm_code)) +/// sim_neo(Qasm::from_string(qasm_code)).auto() /// .classical(qasm_engine()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -704,13 +713,13 @@ impl SimNeoInput for pecos_programs::Qasm { /// Use `.auto()` to automatically select the HUGR interpreter engine: /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_programs::Hugr; /// /// let hugr = Hugr::from_file("program.hugr").unwrap(); /// sim_neo(hugr) /// .auto() -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -726,13 +735,13 @@ impl SimNeoInput for pecos_programs::Hugr { /// the program type: /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_programs::{Program, Qasm}; /// /// let qasm = Qasm::from_string("OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];".to_string()); /// sim_neo(Program::Qasm(qasm)) /// .auto() -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -773,8 +782,8 @@ impl Default for SimConfig { /// Builder for importance sampling configuration. /// -/// Specifies the true error rates and boost factor for biased sampling. -/// Use the [`importance_sampling()`] function to create an instance. +/// Specifies the shot count, true error rates, and boost factor for biased +/// sampling. Use the [`importance_sampling()`] function to create an instance. /// /// # Example /// @@ -783,18 +792,21 @@ impl Default for SimConfig { /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let results = sim_neo(circuit) -/// .sampling(importance_sampling() +/// let results = sim_neo(circuit).auto() +/// .sampling(importance_sampling(10000) /// .with_p1(0.001) /// .with_p2(0.01) /// .with_p_meas(0.001) /// .with_boost(10.0)) -/// .shots(10000) /// .build() /// .run(); /// ``` #[derive(Debug, Clone)] pub struct ImportanceSamplingBuilder { + /// Number of (boosted) trials to run. + shots: usize, + /// Number of parallel workers (1 = sequential). + workers: usize, /// Single-qubit gate error rate (true distribution). p1: f64, /// Two-qubit gate error rate (true distribution). @@ -806,12 +818,14 @@ pub struct ImportanceSamplingBuilder { } impl ImportanceSamplingBuilder { - /// Create a new importance sampling builder with default values. + /// Create a new importance sampling builder running `shots` trials. /// - /// Default: p1=0.001, p2=0.01, `p_meas=0.001`, boost=10.0 + /// Default rates: p1=0.001, p2=0.01, `p_meas=0.001`, boost=10.0 #[must_use] - pub fn new() -> Self { + pub fn new(shots: usize) -> Self { Self { + shots, + workers: 1, p1: 0.001, p2: 0.01, p_meas: 0.001, @@ -819,6 +833,23 @@ impl ImportanceSamplingBuilder { } } + /// Set the number of parallel workers (1 = sequential). + /// + /// Trials are seeded per global shot index, so results are identical + /// for any worker count. + #[must_use] + pub fn workers(mut self, workers: usize) -> Self { + self.workers = workers; + self + } + + /// Set the worker count from available parallelism. + #[must_use] + pub fn auto_workers(mut self) -> Self { + self.workers = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); + self + } + /// Set the single-qubit gate error rate. #[must_use] pub fn with_p1(mut self, p: f64) -> Self { @@ -888,11 +919,11 @@ impl ImportanceSamplingBuilder { pub fn boost(&self) -> f64 { self.boost } -} -impl Default for ImportanceSamplingBuilder { - fn default() -> Self { - Self::new() + /// Get the number of (boosted) trials to run. + #[must_use] + pub fn shots(&self) -> usize { + self.shots } } @@ -902,7 +933,7 @@ impl From for Sampling { } } -/// Create an importance sampling strategy builder. +/// Create an importance sampling strategy builder running `shots` trials. /// /// Importance sampling biases noise toward higher error rates to observe /// rare events more frequently, then reweights results for unbiased estimates. @@ -914,12 +945,11 @@ impl From for Sampling { /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let results = sim_neo(circuit) -/// .sampling(importance_sampling() +/// let results = sim_neo(circuit).auto() +/// .sampling(importance_sampling(10000) /// .with_p1(0.001) /// .with_p2(0.01) /// .with_boost(10.0)) -/// .shots(10000) /// .build() /// .run(); /// @@ -932,8 +962,270 @@ impl From for Sampling { /// } /// ``` #[must_use] -pub fn importance_sampling() -> ImportanceSamplingBuilder { - ImportanceSamplingBuilder::new() +pub fn importance_sampling(shots: usize) -> ImportanceSamplingBuilder { + ImportanceSamplingBuilder::new(shots) +} + +// The Monte Carlo sampling vocabulary (`monte_carlo()` + `MonteCarloBuilder`) +// is shared with the engines stack and lives in `pecos-engines` so BOTH stacks +// accept the same `monte_carlo(n).workers(m)` spelling (the engines +// `MonteCarloEngine`'s run-spec). Re-exported here so `pecos_neo::tool::*` +// paths are unchanged; neo maps it into its own `Sampling` strategy below. +pub use pecos_engines::sampling::{MonteCarloBuilder, monte_carlo}; + +impl From for Sampling { + fn from(builder: MonteCarloBuilder) -> Self { + Sampling::MonteCarlo { + shots: builder.shots(), + workers: builder.resolved_workers(), + } + } +} + +/// Builder for the path enumeration sampling strategy. +/// +/// Created by [`path_enumeration()`]. +#[derive(Debug, Clone)] +pub struct PathEnumerationBuilder { + max_measurements: usize, +} + +impl From for Sampling { + fn from(builder: PathEnumerationBuilder) -> Self { + Sampling::PathEnumeration { config: builder } + } +} + +/// Create a path enumeration strategy covering up to `max_measurements` +/// random measurement branches. +/// +/// Instead of sampling, this systematically enumerates the measurement +/// branches of a (noiseless) Clifford circuit: every random measurement +/// splits the execution into two equal-probability paths. Each distinct +/// realized path becomes one entry in `SimulationResults::outcomes`, with +/// its exact probability in `SimulationResults::weights` +/// (`2^-{number of random measurements}` along that path; deterministic +/// measurements do not branch). +/// +/// If `max_measurements` covers every random measurement in the circuit, +/// the enumeration is complete and the weights sum to 1. If the circuit +/// has more random measurements than `max_measurements`, uncovered +/// branches default to outcome 0 and the weights sum to less than 1. +/// +/// Requires a static circuit on the `sparse_stab()` backend with no +/// `.noise()` (noise makes branching stochastic beyond measurements); +/// checked at `.build()`. The rerun override `Simulation::shots()` has no +/// effect on enumeration size. +/// +/// # Example +/// +/// ```no_run +/// use pecos_neo::tool::{path_enumeration, sim_neo, sparse_stab}; +/// use pecos_neo::prelude::*; +/// +/// let circuit = CommandBuilder::new() +/// .pz(&[0, 1]) +/// .h(&[0]) +/// .cx(&[(0, 1)]) +/// .mz(&[0, 1]) +/// .build(); +/// +/// let results = sim_neo(circuit) +/// .quantum(sparse_stab()) +/// .sampling(path_enumeration(1)) +/// .run(); +/// +/// // Two paths (00 and 11), each with probability 0.5. +/// for (outcome, weight) in results +/// .outcomes +/// .iter() +/// .zip(results.weights.as_ref().unwrap()) +/// { +/// println!("p = {:.3}: {:?}", weight.weight(), outcome); +/// } +/// ``` +#[must_use] +pub fn path_enumeration(max_measurements: usize) -> PathEnumerationBuilder { + PathEnumerationBuilder { max_measurements } +} + +/// Score function for subset simulation: how "close" an outcome is to the +/// failure event (higher = closer). +pub type SubsetScoreFn = Arc f64 + Send + Sync>; + +/// Failure predicate for subset simulation: did this outcome reach the +/// rare event? +pub type SubsetFailureFn = Arc bool + Send + Sync>; + +/// Builder for the subset simulation sampling strategy. +/// +/// Created by [`subset_simulation()`]. `samples_per_level` is the defining +/// argument; `.score()` and `.failure()` are required (there is no sensible +/// default for either), checked at `.build()`. +#[derive(Clone)] +pub struct SubsetSimulationBuilder { + samples_per_level: usize, + threshold_fraction: f64, + max_levels: usize, + min_conditional_prob: f64, + /// Explicit acknowledgment that the multi-level estimator is biased. + /// Required before `max_levels > 1` is allowed (see + /// [`SubsetSimulationBuilder::allow_biased_multilevel`]). + allow_biased_multilevel: bool, + score: Option, + failure: Option, +} + +impl std::fmt::Debug for SubsetSimulationBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubsetSimulationBuilder") + .field("samples_per_level", &self.samples_per_level) + .field("threshold_fraction", &self.threshold_fraction) + .field("max_levels", &self.max_levels) + .field("allow_biased_multilevel", &self.allow_biased_multilevel) + .field("min_conditional_prob", &self.min_conditional_prob) + .field("score", &self.score.as_ref().map(|_| "Fn(..) -> f64")) + .field("failure", &self.failure.as_ref().map(|_| "Fn(..) -> bool")) + .finish() + } +} + +impl SubsetSimulationBuilder { + /// Set the score function: how "close" is this outcome to failure? + /// + /// Higher scores advance to the next level. The score must be + /// consistent with `.failure()`: failing outcomes should score at + /// least as high as any non-failing outcome. + #[must_use] + pub fn score(mut self, score: F) -> Self + where + F: Fn(&MeasurementOutcomes) -> f64 + Send + Sync + 'static, + { + self.score = Some(Arc::new(score)); + self + } + + /// Set the failure predicate: did this outcome reach the rare event? + #[must_use] + pub fn failure(mut self, failure: F) -> Self + where + F: Fn(&MeasurementOutcomes) -> bool + Send + Sync + 'static, + { + self.failure = Some(Arc::new(failure)); + self + } + + /// Set the fraction of samples that advances past each threshold + /// (typically 0.1-0.2; default 0.1). + #[must_use] + pub fn threshold_fraction(mut self, fraction: f64) -> Self { + self.threshold_fraction = fraction; + self + } + + /// Set the maximum number of levels before giving up. + /// + /// Defaults to 1 (a single level, which is an unbiased direct-Monte- + /// Carlo estimate of the failure fraction). Setting more than one level + /// engages the multi-level estimator, which is currently BIASED upward + /// (the resample is unconditioned; see the `sampling::subset` module + /// docs). Requesting `levels > 1` therefore also requires an explicit + /// [`Self::allow_biased_multilevel`] acknowledgment, or `.run()` fails + /// at build time. + #[must_use] + pub fn max_levels(mut self, levels: usize) -> Self { + self.max_levels = levels; + self + } + + /// Acknowledge and accept the known upward bias of the multi-level + /// subset estimator, enabling `max_levels > 1`. + /// + /// Without this, [`subset_simulation`] runs a single unbiased level + /// (direct Monte Carlo). The multi-level path provides rare-event + /// reach but is currently biased (see the `sampling::subset` module + /// docs and the estimator-overhaul follow-up); call this only when an + /// approximate, biased estimate is acceptable. + #[must_use] + pub fn allow_biased_multilevel(mut self) -> Self { + self.allow_biased_multilevel = true; + self + } + + /// Set the minimum conditional probability before declaring the + /// failure event unreachable (default 1e-6). + #[must_use] + pub fn min_conditional_prob(mut self, p: f64) -> Self { + self.min_conditional_prob = p; + self + } +} + +impl From for Sampling { + fn from(builder: SubsetSimulationBuilder) -> Self { + Sampling::SubsetSimulation { config: builder } + } +} + +/// Create a subset simulation strategy builder running `samples_per_level` +/// samples at each level. +/// +/// Subset simulation estimates rare event probabilities by decomposing +/// them into a product of conditional probabilities across adaptive +/// levels. It needs a `.score()` function (how close an outcome is to +/// failure) and a `.failure()` predicate (did the rare event occur); +/// both are required. +/// +/// Accuracy caveat: the current multi-level estimator biases upward once +/// more than one level engages (see `sampling::subset` module docs); +/// treat deep-rare-event estimates as approximate until the estimator +/// overhaul lands. +/// +/// The result arrives in [`SimulationResults::subset`]; per-shot +/// `outcomes` are empty for subset runs. +/// +/// Currently supports static circuits on the `sparse_stab()` backend only. +/// +/// # Example +/// +/// ```no_run +/// use pecos_neo::tool::{sim_neo, sparse_stab, subset_simulation}; +/// use pecos_neo::prelude::*; +/// +/// let circuit = CommandBuilder::new() +/// .pz(&[0, 1, 2]) +/// .h(&[0, 1, 2]) +/// .mz(&[0, 1, 2]) +/// .build(); +/// +/// let results = sim_neo(circuit) +/// .quantum(sparse_stab()) +/// .sampling( +/// subset_simulation(1000) +/// .score(|o| o.iter().filter(|m| m.outcome).count() as f64) +/// .failure(|o| o.iter().all(|m| m.outcome)), +/// ) +/// .seed(42) +/// .run(); +/// +/// let subset = results.subset.expect("subset strategy produces an estimate"); +/// println!("P(failure) = {:.2e}", subset.probability()); +/// ``` +#[must_use] +pub fn subset_simulation(samples_per_level: usize) -> SubsetSimulationBuilder { + let defaults = SubsetConfig::default(); + SubsetSimulationBuilder { + samples_per_level, + threshold_fraction: defaults.threshold_fraction, + // Default to a single, unbiased level. The multi-level estimator is + // biased upward, so engaging it (`max_levels > 1`) requires an + // explicit `.allow_biased_multilevel()` acknowledgment. + max_levels: 1, + min_conditional_prob: defaults.min_conditional_prob, + allow_biased_multilevel: false, + score: None, + failure: None, + } } /// Sampling strategy for simulation execution. @@ -943,51 +1235,112 @@ pub fn importance_sampling() -> ImportanceSamplingBuilder { /// /// Stored as data in the builder, the actual execution is set up at run time. /// -/// The default is `MonteCarlo { workers: 1 }`, which runs shots sequentially -/// using the Tool/Schedule/Plugin system. Use `.workers(n)` or `.auto_workers()` -/// for parallel execution. -#[derive(Debug, Clone)] +/// Construct via the builder functions [`monte_carlo()`] and +/// [`importance_sampling()`] and pass to +/// [`SimNeoBuilder::sampling()`](SimNeoBuilder::sampling). There is no +/// default: the shot count is part of the strategy and must be explicit. +#[derive(Clone)] pub enum Sampling { /// Monte Carlo execution (sequential with 1 worker, parallel with >1). /// /// Each worker runs a batch of shots independently with deterministic seeding. /// Supports both noiseless and noisy circuits (noise model is cloned per worker). /// With 1 worker, runs via the Tool's schedule directly. + /// + /// Use the [`monte_carlo()`] builder function to create this variant. MonteCarlo { - /// Number of parallel workers (default: 1). + /// Number of shots to run. + shots: usize, + /// Number of parallel workers (1 = sequential). workers: usize, }, /// Importance sampling for rare event estimation. /// /// Biases sampling toward rare events and reweights results. - /// Use when estimating probabilities of rare outcomes. + /// Use when estimating probabilities of rare outcomes (~1e-3 to 1e-6). /// /// Use the [`importance_sampling()`] builder function to create this variant. ImportanceSampling { /// Configuration for importance sampling. config: ImportanceSamplingBuilder, }, + + /// Subset simulation: decompose a rare-event probability into a product + /// of conditional probabilities across adaptive levels. Produces an + /// estimate in [`SimulationResults::subset`] instead of per-shot + /// outcomes. + /// + /// Accuracy caveat: the current multi-level estimator biases upward + /// once more than one level engages (see the `sampling::subset` module + /// docs); treat deep-rare-event estimates as approximate until the + /// estimator overhaul lands. Single-level runs behave like direct + /// Monte Carlo and are unbiased. + /// + /// Use the [`subset_simulation()`] builder function to create this variant. + SubsetSimulation { + /// Configuration for subset simulation. + config: SubsetSimulationBuilder, + }, + + /// Exhaustive enumeration of measurement branches (noiseless circuits). + /// + /// Each distinct realized path becomes one outcome entry with its exact + /// probability as the weight. + /// + /// Use the [`path_enumeration()`] builder function to create this variant. + PathEnumeration { + /// Configuration for path enumeration. + config: PathEnumerationBuilder, + }, } -impl Default for Sampling { - fn default() -> Self { - Self::MonteCarlo { workers: 1 } +impl std::fmt::Debug for Sampling { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MonteCarlo { shots, workers } => f + .debug_struct("MonteCarlo") + .field("shots", shots) + .field("workers", workers) + .finish(), + Self::ImportanceSampling { config } => f + .debug_struct("ImportanceSampling") + .field("config", config) + .finish(), + Self::SubsetSimulation { config } => f + .debug_struct("SubsetSimulation") + .field("config", config) + .finish(), + Self::PathEnumeration { config } => f + .debug_struct("PathEnumeration") + .field("config", config) + .finish(), + } } } impl Sampling { - /// Create a Monte Carlo sampling strategy with specified workers. - #[must_use] - pub fn monte_carlo(workers: usize) -> Self { - Self::MonteCarlo { workers } - } - - /// Create a Monte Carlo sampling strategy with auto-detected worker count. + /// Number of shots/trials this strategy will run. + /// + /// For subset simulation this is the samples per level; the total + /// sample count depends on how many levels the run needs. For path + /// enumeration this is the number of enumerated forced paths + /// (`2^max_measurements`); distinct realized paths may be fewer. #[must_use] - pub fn monte_carlo_auto() -> Self { - let workers = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); - Self::MonteCarlo { workers } + pub fn shots(&self) -> usize { + match self { + Self::MonteCarlo { shots, .. } => *shots, + Self::ImportanceSampling { config } => config.shots(), + Self::SubsetSimulation { config } => config.samples_per_level, + // Saturate rather than overflow the shift: an out-of-range + // max_measurements is rejected with a friendly message at + // build time (the <= 24 path-enumeration assert), and this + // accessor must not panic with a raw shift overflow first. + Self::PathEnumeration { config } => u32::try_from(config.max_measurements) + .ok() + .and_then(|bits| 1usize.checked_shl(bits)) + .unwrap_or(usize::MAX), + } } } @@ -998,6 +1351,14 @@ pub struct SimulationResults { pub outcomes: Vec, /// Per-shot importance weights (only for importance sampling). pub weights: Option>, + /// Rare-event estimate with per-level statistics (only for subset + /// simulation; `outcomes` is empty for subset runs). + pub subset: Option, + /// Per-shot named-register results, populated when the program source + /// produces them (classical engines: QASM cregs, PHIR variables). + /// None for sources without register data; see + /// [`to_shot_vec()`](Self::to_shot_vec) to synthesize from outcomes. + pub shots: Option, } impl SimulationResults { @@ -1025,6 +1386,49 @@ impl SimulationResults { if let Some(ref mut weights) = self.weights { weights.clear(); } + self.subset = None; + self.shots = None; + } + + /// View the results as a [`pecos_results::ShotVec`]. + /// + /// Program sources with named-register data (classical engines) + /// populate [`shots`](Self::shots) directly; that is returned as-is. + /// Otherwise one [`pecos_results::Shot`] per outcome is synthesized: + /// with a `register_map`, one `Data::BitVec` register per named + /// register (bits in the register's qubit order); without one, a + /// single register named `"meas"` with bits in ascending qubit order. + #[must_use] + pub fn to_shot_vec(&self, register_map: Option<&RegisterMap>) -> pecos_results::ShotVec { + if let Some(shots) = &self.shots { + return shots.clone(); + } + + let mut shot_vec = pecos_results::ShotVec::new(); + for outcomes in &self.outcomes { + let mut shot = pecos_results::Shot::default(); + if let Some(map) = register_map { + for name in map.register_names() { + if let Some(bits) = outcomes.register_bitstring(map, name) { + let bitstring: String = + bits.iter().map(|&b| if b { '1' } else { '0' }).collect(); + if let Some(data) = pecos_results::Data::from_bitstring(&bitstring) { + shot.data.insert(name.to_string(), data); + } + } + } + } else { + let bitstring: String = outcomes + .iter() + .map(|o| if o.outcome { '1' } else { '0' }) + .collect(); + if let Some(data) = pecos_results::Data::from_bitstring(&bitstring) { + shot.data.insert("meas".to_string(), data); + } + } + shot_vec.shots.push(shot); + } + shot_vec } /// Check if this result has importance weights. @@ -1085,7 +1489,7 @@ impl SimulationResults { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::outcome::RegisterMap; /// use pecos_neo::prelude::*; /// @@ -1096,7 +1500,7 @@ impl SimulationResults { /// let mut reg = RegisterMap::new(); /// reg.add_register("c", &[QubitId(0), QubitId(1)]); /// - /// let results = sim_neo(circuit).shots(100).seed(42).run(); + /// let results = sim_neo(circuit).auto().sampling(monte_carlo(100)).seed(42).run(); /// let columns = results.as_register_columns(®); /// assert_eq!(columns["c"].len(), 100); /// ``` @@ -1133,7 +1537,7 @@ impl SimulationResults { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::outcome::RegisterMap; /// use pecos_neo::prelude::*; /// @@ -1144,7 +1548,7 @@ impl SimulationResults { /// let mut reg = RegisterMap::new(); /// reg.add_register("c", &[QubitId(0)]); /// - /// let results = sim_neo(circuit).shots(1000).seed(42).run(); + /// let results = sim_neo(circuit).auto().sampling(monte_carlo(1000)).seed(42).run(); /// let counts = results.register_counts(®, "c"); /// // Should have entries for [false] and [true] /// ``` @@ -1388,13 +1792,13 @@ fn infer_num_qubits_from_circuit(circuit: &CommandQueue) -> usize { /// ## Static Circuit /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let results = sim_neo(circuit) +/// let results = sim_neo(circuit).auto() /// .depolarizing(0.01) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -1403,14 +1807,14 @@ fn infer_num_qubits_from_circuit(circuit: &CommandQueue) -> usize { /// ## QASM Program (builder-of-builders pattern) /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// // Pass program source first, then engine factory -/// let results = sim_neo(qasm_code) +/// let results = sim_neo(qasm_code).auto() /// .classical(qasm_engine()) // Engine configured with source at build time -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -1419,14 +1823,14 @@ fn infer_num_qubits_from_circuit(circuit: &CommandQueue) -> usize { /// ## Pre-configured Engine Builder /// /// ```no_run -/// use pecos_neo::tool::sim_neo_builder; +/// use pecos_neo::tool::{monte_carlo, sim_neo_builder}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// // Or pass already-configured engine builder /// let results = sim_neo_builder() /// .with_engine(qasm_engine().qasm(qasm_code)) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -1441,10 +1845,21 @@ pub struct SimNeoBuilder { definitions: Option, /// Simulation configuration (data). config: SimConfig, - /// Sampling strategy (data). - sampling: Sampling, - /// Quantum backend configuration (data). - quantum_backend: QuantumBackend, + /// Sampling strategy (data). None until `.sampling()` is called. + sampling: Option, + /// Shot count from the top-level `.shots()` shortcut (sugar for the + /// default Monte Carlo sampler). Mutually exclusive with `.sampling()`. + shots_shortcut: Option, + /// Worker count from the deprecated top-level `.workers()` forwarder. + legacy_workers: Option, + /// Auto worker-count request from `.auto()`/deprecated `.auto_workers()`, + /// honored only on the top-level `.shots()` shortcut path. + auto_workers_hint: bool, + /// Backend auto-selection opt-in from `.auto()`. + auto_backend: bool, + /// Quantum backend configuration (data). None until `.quantum()` is + /// called; `.auto()` opts into automatic selection at build time. + quantum_backend: Option, /// Explicit qubit count override (data). explicit_num_qubits: Option, /// Maximum decomposition depth for gate resolution. @@ -1456,17 +1871,20 @@ pub struct SimNeoBuilder { } impl SimNeoBuilder { - /// Create a new simulation builder for a circuit. - #[must_use] - pub fn with_circuit(circuit: CommandQueue) -> Self { + /// Create a builder with the given source and all other fields unset. + fn from_source(source: Option) -> Self { Self { - source: Some(ProgramSource::Static(circuit)), + source, pending_builder: None, noise: None, definitions: None, config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), + sampling: None, + shots_shortcut: None, + legacy_workers: None, + auto_workers_hint: false, + auto_backend: false, + quantum_backend: None, explicit_num_qubits: None, max_decomp_depth: None, overrides: None, @@ -1474,22 +1892,16 @@ impl SimNeoBuilder { } } + /// Create a new simulation builder for a circuit. + #[must_use] + pub fn with_circuit(circuit: CommandQueue) -> Self { + Self::from_source(Some(ProgramSource::Static(circuit))) + } + /// Create a simulation builder for a dynamic command source. #[must_use] pub fn with_command_source(source: Box) -> Self { - Self { - source: Some(ProgramSource::Dynamic(source)), - pending_builder: None, - noise: None, - definitions: None, - config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), - explicit_num_qubits: None, - max_decomp_depth: None, - overrides: None, - event_handlers: None, - } + Self::from_source(Some(ProgramSource::Dynamic(source))) } /// Create a simulation builder with raw program source. @@ -1497,19 +1909,7 @@ impl SimNeoBuilder { /// Use `.classical(builder)` to specify how to interpret the source. #[must_use] pub fn with_program_source(source: String) -> Self { - Self { - source: Some(ProgramSource::RawSource(source)), - pending_builder: None, - noise: None, - definitions: None, - config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), - explicit_num_qubits: None, - max_decomp_depth: None, - overrides: None, - event_handlers: None, - } + Self::from_source(Some(ProgramSource::RawSource(source))) } /// Create a simulation builder with a typed program. @@ -1518,19 +1918,7 @@ impl SimNeoBuilder { /// `.classical(builder)` for explicit control. #[must_use] pub fn with_typed_program(program: TypedProgram) -> Self { - Self { - source: Some(ProgramSource::Typed(program)), - pending_builder: None, - noise: None, - definitions: None, - config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), - explicit_num_qubits: None, - max_decomp_depth: None, - overrides: None, - event_handlers: None, - } + Self::from_source(Some(ProgramSource::Typed(program))) } /// Create a new simulation builder for a circuit (legacy alias). @@ -1544,19 +1932,7 @@ impl SimNeoBuilder { /// Use this when you want to set the program source via `.classical()`. #[must_use] pub fn empty() -> Self { - Self { - source: None, - pending_builder: None, - noise: None, - definitions: None, - config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), - explicit_num_qubits: None, - max_decomp_depth: None, - overrides: None, - event_handlers: None, - } + Self::from_source(None) } /// Set the classical control engine builder (builder-of-builders pattern). @@ -1566,14 +1942,14 @@ impl SimNeoBuilder { /// it all together when building the Tool. /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// // Builder is stored as data, source injected at build time - /// let results = sim_neo(qasm_code) + /// let results = sim_neo(qasm_code).auto() /// .classical(qasm_engine()) // stores builder as data - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() // configures builder, builds engine, creates Tool /// .run(); /// ``` @@ -1581,13 +1957,13 @@ impl SimNeoBuilder { /// For pre-configured engine builders, use `.with_engine()` instead: /// /// ```no_run - /// use pecos_neo::tool::sim_neo_builder; + /// use pecos_neo::tool::{monte_carlo, sim_neo_builder}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// let results = sim_neo_builder() /// .with_engine(qasm_engine().qasm(qasm_code)) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -1660,13 +2036,13 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo_builder; + /// use pecos_neo::tool::{monte_carlo, sim_neo_builder}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// let results = sim_neo_builder() /// .with_engine(qasm_engine().qasm(qasm_code)) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -1682,49 +2058,53 @@ impl SimNeoBuilder { self } - /// Automatically select the appropriate engine based on program type. + /// Opt into automatic selection of unset components. + /// + /// `.auto()` is explicit-about-being-implicit: it lets the builder fill + /// in components you did not set, instead of failing at build time. + /// Currently it selects: + /// - The classical engine for typed programs (`Qasm` uses `qasm_engine()`, + /// `Hugr` uses `hugr_engine()`); other sources are left unchanged. + /// - The quantum backend, if `.quantum()` was not called + /// (currently `SparseStab`). /// - /// This is a convenience method that selects good defaults: - /// - `Qasm` programs use `qasm_engine()` - /// - Future: `Hugr`, `PhirJson`, `Qis` will use their respective engines + /// The sampling strategy is never auto-selected: a shot count cannot be + /// guessed, so `.sampling(monte_carlo(shots))` is always required. (On + /// the deprecated top-level `.shots()` path, `.auto()` additionally + /// requests an auto-detected worker count to preserve legacy behavior.) /// /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_programs::Qasm; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];".to_string(); - /// // Auto-select engine based on program type + /// // Auto-select engine and backend /// let results = sim_neo(Qasm::from_string(qasm_code)) /// .auto() - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` /// /// # Panics /// - /// Panics if: - /// - No typed program was provided (use `sim_neo(Qasm::from_string(...))`) - /// - The program type is not yet supported for auto-selection - /// - /// Note: `.auto()` also selects the default Monte Carlo sampling strategy. The - /// parallel execution plan decides whether the selected command source and - /// quantum backend can safely build independent worker state. + /// Panics if a typed program's type is not yet supported for + /// auto-selection, or its engine's cargo feature is disabled. #[must_use] pub fn auto(mut self) -> Self { - match self.source.take() { + self.auto_backend = true; + self.auto_workers_hint = true; + self.source = match self.source.take() { Some(ProgramSource::Typed(typed)) => match typed { #[cfg(feature = "qasm")] TypedProgram::Qasm(qasm) => { // Auto-select qasm_engine() and configure with the program. let builder = pecos_qasm::qasm_engine().qasm(qasm.source); - self.source = Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { + Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { builder, - }))); - self.sampling = Sampling::monte_carlo_auto(); - self + }))) } #[cfg(not(feature = "qasm"))] TypedProgram::Qasm(_) => { @@ -1737,11 +2117,9 @@ impl SimNeoBuilder { TypedProgram::Hugr(hugr) => { // Auto-select hugr_engine() and configure with the program. let builder = pecos_hugr::hugr_engine().hugr_bytes(hugr.hugr); - self.source = Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { + Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { builder, - }))); - self.sampling = Sampling::monte_carlo_auto(); - self + }))) } #[cfg(not(feature = "hugr"))] TypedProgram::Hugr(_) => { @@ -1757,38 +2135,9 @@ impl SimNeoBuilder { ); } }, - Some(ProgramSource::RawSource(_)) => { - panic!( - "Cannot use .auto() with raw string source. \ - Use sim_neo(Qasm::from_string(...)).auto() or \ - sim_neo(source).classical(engine) instead." - ); - } - Some(ProgramSource::Static(_)) => { - panic!( - "Cannot use .auto() with static circuits. \ - Static circuits don't need an engine - just call .build() directly." - ); - } - Some(ProgramSource::Dynamic(_)) => { - panic!( - "Cannot use .auto() with an existing dynamic command source. \ - Command sources are already executable." - ); - } - Some(ProgramSource::Classical(_)) => { - panic!( - "Engine already configured. \ - Don't use both .auto() and .classical()/.with_engine()." - ); - } - None => { - panic!( - "No program provided. \ - Use sim_neo(Qasm::from_string(...)).auto() or similar." - ); - } - } + other => other, + }; + self } /// Set the number of qubits explicitly. @@ -1801,10 +2150,20 @@ impl SimNeoBuilder { self } - /// Set the number of shots. + /// Set the number of Monte Carlo shots to run. + /// + /// This is the shorthand for the common case: `.shots(n)` is exactly + /// `.sampling(monte_carlo(n))`. Reach for [`sampling()`](Self::sampling) + /// directly when you need worker parallelism or a non-Monte-Carlo strategy + /// (e.g. `.sampling(monte_carlo(n).workers(8))` or + /// `.sampling(importance_sampling(n))`). Setting both `.shots()` and + /// `.sampling()` is rejected at build time rather than silently picking one. + /// + /// The facade (`pecos::sim().stack(...)`) exposes the same `.shots(n)`, so + /// both stacks share the `.shots(n).run()` shape. #[must_use] pub fn shots(mut self, shots: usize) -> Self { - self.config.shots = shots; + self.shots_shortcut = Some(shots); self } @@ -1817,58 +2176,54 @@ impl SimNeoBuilder { /// Set the sampling strategy for simulation execution. /// + /// The strategy carries its own shot count and execution knobs; build + /// it with [`monte_carlo()`] or [`importance_sampling()`]. + /// /// # Example /// /// ```no_run - /// use pecos_neo::tool::{sim_neo, Sampling}; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// /// // Parallel Monte Carlo with 4 workers - /// let results = sim_neo(circuit.clone()) - /// .sampling(Sampling::monte_carlo(4)) - /// .shots(1000) + /// let results = sim_neo(circuit.clone()).auto() + /// .sampling(monte_carlo(1000).workers(4)) /// .build() /// .run(); /// /// // Auto-detect worker count - /// let results = sim_neo(circuit) - /// .sampling(Sampling::monte_carlo_auto()) - /// .shots(1000) + /// let results = sim_neo(circuit).auto() + /// .sampling(monte_carlo(1000).auto_workers()) /// .build() /// .run(); /// ``` #[must_use] pub fn sampling(mut self, sampling: impl Into) -> Self { - self.sampling = sampling.into(); + self.sampling = Some(sampling.into()); self } /// Convenience method for parallel Monte Carlo with specified workers. - /// - /// Parallel execution distributes shots across workers using rayon, - /// with each worker getting its own simulator and noise model clone. - /// Works with both noiseless and noisy circuits. - /// - /// # Panics - /// - /// Panics at `.run()` time if parallel execution is not possible. - /// Parallel execution requires a static circuit using a built-in - /// backend (`SparseStab` or `StateVec`). Classical engines and custom - /// backends are not supported. + #[deprecated( + since = "0.2.0", + note = "workers lives on the sampler builder: use .sampling(monte_carlo(shots).workers(n))" + )] #[must_use] pub fn workers(mut self, workers: usize) -> Self { - self.sampling = Sampling::monte_carlo(workers); + self.legacy_workers = Some(workers); self } /// Convenience method for parallel Monte Carlo with auto-detected workers. - /// - /// See [`workers()`](Self::workers) for requirements and panics. + #[deprecated( + since = "0.2.0", + note = "workers lives on the sampler builder: use .sampling(monte_carlo(shots).auto_workers())" + )] #[must_use] pub fn auto_workers(mut self) -> Self { - self.sampling = Sampling::monte_carlo_auto(); + self.auto_workers_hint = true; self } @@ -1877,34 +2232,38 @@ impl SimNeoBuilder { /// This selects which quantum simulator to use. Different backends have /// different capabilities and performance characteristics: /// - /// - `sparse_stab()` - Sparse stabilizer (default), efficient for Clifford circuits + /// - `sparse_stab()` - Sparse stabilizer, efficient for Clifford circuits /// - `state_vector()` - State vector, supports arbitrary gates including T and rotations /// + /// A backend must be chosen: either call `.quantum()` explicitly or opt + /// into automatic selection with `.auto()`. A missing backend is a + /// build-time error. + /// /// # Example /// /// ```no_run - /// use pecos_neo::tool::{sim_neo, sparse_stab, state_vector}; + /// use pecos_neo::tool::{monte_carlo, sim_neo, sparse_stab, state_vector}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// - /// // Use sparse stabilizer (default, Clifford-only) + /// // Use sparse stabilizer (Clifford-only) /// let results = sim_neo(circuit.clone()) /// .quantum(sparse_stab()) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// /// // Use state vector (supports T gates, rotations) /// let results = sim_neo(circuit) /// .quantum(state_vector()) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` #[must_use] pub fn quantum>(mut self, backend: B) -> Self { - self.quantum_backend = backend.into(); + self.quantum_backend = Some(backend.into()); self } @@ -1923,19 +2282,19 @@ impl SimNeoBuilder { /// # Examples /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// use pecos_neo::noise::GeneralNoiseModelBuilder; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// /// // Using GeneralNoiseModelBuilder (no .build() needed) - /// sim_neo(circuit.clone()) + /// sim_neo(circuit.clone()).auto() /// .noise(GeneralNoiseModelBuilder::new().with_p1(0.01).with_p2(0.02)) /// .build(); /// /// // Using a single channel directly - /// sim_neo(circuit.clone()) + /// sim_neo(circuit.clone()).auto() /// .noise(SingleQubitChannel::depolarizing(0.01)) /// .build(); /// ``` @@ -1954,15 +2313,15 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let defs = GateDefinitions::new(); // core gates included by default /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - /// let results = sim_neo(circuit) + /// let results = sim_neo(circuit).auto() /// .gate_definitions(defs) - /// .shots(100) + /// .sampling(monte_carlo(100)) /// .seed(42) /// .build() /// .run(); @@ -1982,13 +2341,13 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - /// let results = sim_neo(circuit) + /// let results = sim_neo(circuit).auto() /// .max_decomp_depth(20) - /// .shots(100) + /// .sampling(monte_carlo(100)) /// .seed(42) /// .run(); /// ``` @@ -2016,7 +2375,7 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// use pecos_simulators::SparseStab; /// @@ -2027,9 +2386,9 @@ impl SimNeoBuilder { /// }); /// /// let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - /// let results = sim_neo(circuit) + /// let results = sim_neo(circuit).auto() /// .gate_overrides(overrides) - /// .shots(100) + /// .sampling(monte_carlo(100)) /// .seed(42) /// .run(); /// ``` @@ -2047,7 +2406,7 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// use std::sync::atomic::{AtomicUsize, Ordering}; /// use std::sync::Arc; @@ -2062,9 +2421,9 @@ impl SimNeoBuilder { /// }); /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - /// let results = sim_neo(circuit) + /// let results = sim_neo(circuit).auto() /// .event_handlers(handlers) - /// .shots(100) + /// .sampling(monte_carlo(100)) /// .seed(42) /// .run(); /// ``` @@ -2102,12 +2461,22 @@ impl SimNeoBuilder { /// /// This is where all the collected builders and configuration come together: /// - Program source is wired with engine factory (if applicable) + /// - Sampling strategy is resolved and validated /// - Noise model is built /// - Tool is constructed with all plugins and systems /// /// # Panics /// - /// Panics if no program source is set (neither circuit nor classical engine). + /// Panics if: + /// - No program source is set (neither circuit nor classical engine) + /// - No sampling strategy is set (use `.sampling(monte_carlo(shots))`) + /// - No quantum backend is set (use `.quantum(..)` or `.auto()`) + /// - Deprecated `.shots()`/`.workers()` are combined with `.sampling()` + /// - Parallel Monte Carlo (`workers > 1`) is requested for a + /// configuration that cannot build per-worker state (pre-built dynamic + /// command sources) + /// - Subset simulation is missing `.score()`/`.failure()`, or is used + /// with a non-static source or a backend other than `sparse_stab()` #[must_use] pub fn build(self) -> Simulation { // Resolve the program source - configure pending builder with source if needed @@ -2151,26 +2520,227 @@ impl SimNeoBuilder { } }; - let parallel_plan = match self.sampling { - Sampling::MonteCarlo { workers } if workers > 1 => build_parallel_execution_plan( - &source, - &self.quantum_backend, - self.explicit_num_qubits, - self.noise.clone(), - self.definitions.clone(), - self.max_decomp_depth, - self.overrides.clone(), - self.event_handlers.clone(), + // Resolve the sampling strategy: the .sampling() path carries its own + // shot count; the top-level .shots() shortcut (and the deprecated + // .workers() forwarder) map onto Monte Carlo. Mixing .shots()/.workers() + // with .sampling() is ambiguous and rejected. + let sampling = match (self.sampling, self.shots_shortcut) { + (Some(sampling), None) => { + assert!( + self.legacy_workers.is_none(), + "Conflicting sampling configuration: deprecated .workers() cannot be \ + combined with .sampling(). Set workers on the sampler builder, e.g. \ + .sampling(monte_carlo(1000).workers(8))." + ); + sampling + } + (Some(_), Some(_)) => panic!( + "Conflicting sampling configuration: .shots() cannot be combined with \ + .sampling() (both set the shot count). Use one: .shots(1000) for the common \ + case, or .sampling(monte_carlo(1000).workers(8)) for parallel/other strategies." + ), + (None, Some(shots)) => { + let workers = self.legacy_workers.unwrap_or_else(|| { + if self.auto_workers_hint { + std::thread::available_parallelism().map_or(1, std::num::NonZero::get) + } else { + 1 + } + }); + Sampling::MonteCarlo { shots, workers } + } + (None, None) => panic!( + "No sampling strategy set. Use .sampling(monte_carlo(shots)) for Monte Carlo \ + or .sampling(importance_sampling(shots)) for rare-event estimation." ), - _ => None, }; - let mut tool = Tool::new() - .insert_resource(ProgramSourceResource(source)) - .insert_resource(self.config) - .insert_resource(QuantumBackendResource(self.quantum_backend)); + // The shot count drives the Tool's run loop via the SimConfig resource. + let mut config = self.config; + config.shots = sampling.shots(); - match &self.sampling { + // Resolve the quantum backend: explicit .quantum() wins; .auto() opts + // into automatic selection; otherwise fail fast. + let auto_backend = self.auto_backend; + let quantum_backend = self.quantum_backend.unwrap_or_else(|| { + assert!( + auto_backend, + "No quantum backend set. Use .quantum(sparse_stab()) or \ + .quantum(state_vector()), or call .auto() to let sim_neo choose." + ); + QuantumBackend::SparseStab + }); + + // Configuration/backend mismatches are knowable now; fail at build + // instead of at startup. The startup-time checks remain as defensive + // duplicates for direct Tool users. + if let Some(overrides) = &self.overrides { + validate_overrides_backend(overrides, &quantum_backend); + } + match &quantum_backend { + QuantumBackend::AdaptedQuantumEngine(_) => { + reject_dynamic_runner_config( + "QuantumEngineBuilder backend", + self.definitions.as_ref(), + self.max_decomp_depth.as_ref(), + self.overrides.as_ref(), + self.event_handlers.as_ref(), + ); + assert!( + self.noise.is_none(), + "QuantumEngineBuilder backends do not support sim_neo noise modeling. \ + Use a noise-modeling runner/backend instead." + ); + } + QuantumBackend::Custom(factory) => { + reject_dynamic_runner_config( + factory.diagnostic_label(), + self.definitions.as_ref(), + self.max_decomp_depth.as_ref(), + self.overrides.as_ref(), + self.event_handlers.as_ref(), + ); + } + _ => {} + } + + let parallel_plan = match &sampling { + Sampling::MonteCarlo { workers, .. } if *workers > 1 => { + let plan = build_parallel_execution_plan( + &source, + &quantum_backend, + self.explicit_num_qubits, + self.noise.clone(), + self.definitions.clone(), + self.max_decomp_depth, + self.overrides.clone(), + self.event_handlers.clone(), + ); + assert!( + plan.is_some(), + "Parallel Monte Carlo (workers > 1) requires per-worker construction: \ + a static circuit or classical engine builder source. Pre-built dynamic \ + command sources cannot build per-worker state; remove .workers(..) for \ + sequential execution." + ); + plan + } + _ => None, + }; + + // Importance sampling requires a static circuit; this is knowable + // now, so fail at build time. Parallel IS runs outside the Tool + // schedule and needs the circuit captured. + let is_parallel_spec = match &sampling { + Sampling::ImportanceSampling { config: is_config } => { + let ProgramSource::Static(circuit) = &source else { + panic!( + "Importance sampling requires a static circuit. \ + Classical engines are not supported." + ) + }; + if is_config.workers > 1 { + let circuit = circuit.clone(); + let num_qubits = self + .explicit_num_qubits + .unwrap_or_else(|| infer_num_qubits_from_circuit(&circuit)); + Some(StaticCircuitSpec { + circuit, + num_qubits, + }) + } else { + None + } + } + _ => None, + }; + + // Path enumeration runs outside the Tool schedule; validate its + // requirements here and capture what the run needs. + let path_spec = match &sampling { + Sampling::PathEnumeration { config: pe_config } => { + assert!( + pe_config.max_measurements <= 24, + "Path enumeration covers 2^max_measurements paths; \ + max_measurements = {} would enumerate more than 16M paths. \ + Use subset_simulation or importance_sampling for larger spaces.", + pe_config.max_measurements + ); + let circuit = match &source { + ProgramSource::Static(circuit) => circuit.clone(), + _ => panic!( + "Path enumeration requires a static circuit. Classical engines \ + and dynamic command sources are not supported." + ), + }; + assert!( + matches!(quantum_backend, QuantumBackend::SparseStab), + "Path enumeration currently supports only the sparse_stab() backend \ + (or .auto())." + ); + assert!( + self.noise.is_none(), + "Path enumeration enumerates measurement branches of the noiseless \ + circuit; remove .noise()." + ); + let num_qubits = self + .explicit_num_qubits + .unwrap_or_else(|| infer_num_qubits_from_circuit(&circuit)); + Some(StaticCircuitSpec { + circuit, + num_qubits, + }) + } + _ => None, + }; + + // Subset simulation runs outside the Tool schedule, driving + // CircuitRunner directly; validate its requirements here and capture + // what the run needs. + let subset_spec = match &sampling { + Sampling::SubsetSimulation { config: ss_config } => { + assert!( + ss_config.score.is_some() && ss_config.failure.is_some(), + "Subset simulation requires both .score(..) and .failure(..) on the \ + subset_simulation(..) builder; neither has a sensible default." + ); + assert!( + ss_config.max_levels <= 1 || ss_config.allow_biased_multilevel, + "subset_simulation with max_levels > 1 engages the multi-level estimator, \ + which is currently biased upward (the resample is unconditioned). Either \ + keep a single level (an unbiased direct-Monte-Carlo failure-fraction \ + estimate) or call .allow_biased_multilevel() to accept the documented bias." + ); + let circuit = match &source { + ProgramSource::Static(circuit) => circuit.clone(), + _ => panic!( + "Subset simulation requires a static circuit. Classical engines \ + and dynamic command sources are not supported." + ), + }; + assert!( + matches!(quantum_backend, QuantumBackend::SparseStab), + "Subset simulation currently supports only the sparse_stab() backend \ + (or .auto())." + ); + let num_qubits = self + .explicit_num_qubits + .unwrap_or_else(|| infer_num_qubits_from_circuit(&circuit)); + Some(SubsetRunSpec { + circuit, + num_qubits, + noise: self.noise.clone(), + }) + } + _ => None, + }; + + let mut tool = Tool::new() + .insert_resource(ProgramSourceResource(source)) + .insert_resource(config) + .insert_resource(QuantumBackendResource(quantum_backend)); + + match &sampling { Sampling::ImportanceSampling { config: is_config } => { tool = tool.add_plugin(&ImportanceSamplingSimPlugin { is_config: is_config.clone(), @@ -2182,6 +2752,9 @@ impl SimNeoBuilder { explicit_num_qubits: self.explicit_num_qubits, }); } + // Subset simulation and path enumeration do not use the Tool + // schedule; no plugin. + Sampling::SubsetSimulation { .. } | Sampling::PathEnumeration { .. } => {} } // Add noise if configured @@ -2211,8 +2784,11 @@ impl SimNeoBuilder { Simulation { tool, - sampling: self.sampling, + sampling, parallel_plan, + subset_spec, + is_parallel_spec, + path_spec, } } @@ -2224,13 +2800,13 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; - /// let results = sim_neo(qasm_code) + /// let results = sim_neo(qasm_code).auto() /// .classical(qasm_engine()) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .run(); // builds and runs /// ``` #[must_use] @@ -2323,12 +2899,36 @@ pub struct UnifiedShotState { pub shot_index: usize, } +/// Validate that stored gate overrides match the resolved backend. +/// +/// The mismatch is knowable at build time; messages mirror the startup-time +/// checks for each backend. +fn validate_overrides_backend(overrides: &StoredOverrides, backend: &QuantumBackend) { + let override_kind = match overrides { + StoredOverrides::SparseStab(_) => "SparseStab", + StoredOverrides::Stabilizer(_) => "Stabilizer", + StoredOverrides::StateVec(_) => "StateVec", + }; + let backend_kind = match backend { + QuantumBackend::SparseStab => "SparseStab", + QuantumBackend::Stabilizer => "Stabilizer", + QuantumBackend::StateVec => "StateVec", + // Adapted/custom backends reject overrides wholesale elsewhere. + QuantumBackend::AdaptedQuantumEngine(_) | QuantumBackend::Custom(_) => return, + }; + assert!( + override_kind == backend_kind, + "{override_kind} gate overrides used with {backend_kind} backend. \ + Use GateOverrides::<{backend_kind}> instead." + ); +} + fn reject_dynamic_runner_config( backend_name: &str, - definitions: Option<&GateDefinitionsResource>, - max_depth: Option<&MaxDecompDepthResource>, - overrides: Option<&GateOverridesResource>, - event_handlers: Option<&EventHandlersResource>, + definitions: Option<&GateDefinitions>, + max_depth: Option<&usize>, + overrides: Option<&StoredOverrides>, + event_handlers: Option<&EventHandlers>, ) { assert!( definitions.is_none(), @@ -2623,10 +3223,10 @@ fn unified_simulation_startup(resources: &mut Resources) { QuantumBackend::AdaptedQuantumEngine(factory) => { reject_dynamic_runner_config( "QuantumEngineBuilder backend", - definitions.as_ref(), - max_depth.as_ref(), - overrides.as_ref(), - event_handlers.as_ref(), + definitions.as_ref().map(|d| &d.0), + max_depth.as_ref().map(|d| &d.0), + overrides.as_ref().map(|o| &o.0), + event_handlers.as_ref().map(|h| &h.0), ); assert!( noise.is_none(), @@ -2639,10 +3239,10 @@ fn unified_simulation_startup(resources: &mut Resources) { QuantumBackend::Custom(factory) => { reject_dynamic_runner_config( factory.diagnostic_label(), - definitions.as_ref(), - max_depth.as_ref(), - overrides.as_ref(), - event_handlers.as_ref(), + definitions.as_ref().map(|d| &d.0), + max_depth.as_ref().map(|d| &d.0), + overrides.as_ref().map(|o| &o.0), + event_handlers.as_ref().map(|h| &h.0), ); // Custom backends create their own runner; gate definitions // should be captured in the factory closure if needed. @@ -2694,6 +3294,37 @@ fn unified_simulation_post_shot(resources: &mut Resources) { .outcomes .push(outcomes.0); + // Collect rich register results when the source produces them + // (classical engines: QASM cregs, PHIR variables). + let shot = resources + .get::() + .command_source + .shot_results(); + if let Some(shot) = shot { + resources + .get_mut::() + .shots + .get_or_insert_with(pecos_results::ShotVec::new) + .shots + .push(shot); + } + + // `shots[i]` is consumed as the register view of `outcomes[i]`, so the + // two must stay index-aligned: a source must produce a shot record for + // every shot or for none. Anything in between (a source that returns + // Some on some shots and None on others) silently misaligns them. + let results = resources.get::(); + debug_assert!( + results + .shots + .as_ref() + .is_none_or(|shots| shots.shots.len() == results.outcomes.len()), + "shot records and outcomes are misaligned ({} shot records vs {} outcomes); \ + a CommandSource must return shot_results() for every shot or for none", + results.shots.as_ref().map_or(0, |s| s.shots.len()), + results.outcomes.len(), + ); + // Increment shot counter resources.get_mut::().shot_index += 1; } @@ -2747,6 +3378,33 @@ struct ISCurrentResult { weight: crate::sampling::weight::SampleWeight, } +/// Build an importance sampling runner from builder config. +fn build_importance_runner( + is_config: &ImportanceSamplingBuilder, + num_qubits: usize, +) -> ImportanceSamplingRunner { + ImportanceSamplingRunner::new(SparseStab::new(num_qubits)) + .with_single_qubit_boost(is_config.p1(), is_config.boost()) + .with_two_qubit_boost(is_config.p2(), is_config.boost()) + .with_measurement_boost(is_config.p_meas(), is_config.boost()) +} + +/// Seed an importance sampling runner for a specific global shot index. +/// +/// Seeds derive from (`base_seed`, `shot_index`) only, so results are +/// identical whether shots run sequentially or partitioned across workers. +fn seed_importance_runner( + runner: &mut ImportanceSamplingRunner, + base_seed: u64, + shot_index: usize, +) { + let shot_seed = derive_seed(base_seed, &format!("shot_{shot_index}")); + runner.rng = PecosRng::seed_from_u64(derive_seed(shot_seed, "noise")); + runner + .simulator + .set_seed(derive_seed(shot_seed, "simulator")); +} + /// Startup system for importance sampling simulation. fn is_sim_startup(resources: &mut Resources) { let explicit_qubits = resources.get::().0; @@ -2792,11 +3450,7 @@ fn is_sim_startup(resources: &mut Resources) { // Also consume NoiseResource if present (IS uses its own boosted noise) let _ = resources.try_remove::(); - let sim = SparseStab::new(num_qubits); - let runner = ImportanceSamplingRunner::new(sim) - .with_single_qubit_boost(is_config.p1(), is_config.boost()) - .with_two_qubit_boost(is_config.p2(), is_config.boost()) - .with_measurement_boost(is_config.p_meas(), is_config.boost()); + let runner = build_importance_runner(&is_config, num_qubits); resources.insert(ISShotState { runner, @@ -2811,17 +3465,19 @@ fn is_sim_startup(resources: &mut Resources) { } /// Pre-shot system for importance sampling: derive and set per-shot seeds. +/// +/// Only reseeds when a base seed was configured, mirroring the unified +/// Monte Carlo path ([`unified_simulation_pre_shot`]): an unseeded run +/// keeps the runner's entropy-seeded RNG rather than silently forcing a +/// deterministic stream. fn is_sim_pre_shot(resources: &mut Resources) { let config = resources.get::().clone(); let state = resources.get_mut::(); - let base_seed = config.seed.unwrap_or(0); - let shot_seed = derive_seed(base_seed, &format!("shot_{}", state.shot_index)); - let sim_seed = derive_seed(shot_seed, "simulator"); - let noise_seed = derive_seed(shot_seed, "noise"); - - state.runner.rng = PecosRng::seed_from_u64(noise_seed); - state.runner.simulator.set_seed(sim_seed); + if let Some(base_seed) = config.seed { + let shot_index = state.shot_index; + seed_importance_runner(&mut state.runner, base_seed, shot_index); + } } /// Execute system for importance sampling: run one shot with biased noise. @@ -2858,11 +3514,11 @@ fn is_sim_post_shot(resources: &mut Resources) { /// # Example /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let mut sim = sim_neo(circuit).shots(1000).build(); +/// let mut sim = sim_neo(circuit).auto().sampling(monte_carlo(1000)).build(); /// /// let results1 = sim.run(); /// @@ -2876,6 +3532,25 @@ pub struct Simulation { sampling: Sampling, /// Data-oriented plan for parallel execution (if applicable). parallel_plan: Option, + /// Captured inputs for subset simulation (if applicable). + subset_spec: Option, + /// Captured inputs for parallel importance sampling (if applicable). + is_parallel_spec: Option, + /// Captured inputs for path enumeration (if applicable). + path_spec: Option, +} + +/// Inputs captured at build time for a subset simulation run. +struct SubsetRunSpec { + circuit: CommandQueue, + num_qubits: usize, + noise: Option, +} + +/// Inputs captured at build time for a parallel importance sampling run. +struct StaticCircuitSpec { + circuit: CommandQueue, + num_qubits: usize, } /// Native backend used by the internal parallel runner factory. @@ -3027,6 +3702,26 @@ impl ParallelQuantumRunnerFactory for NativeQuantumRunnerFactory { } } } +/// Per-worker runner factory for custom `SimulatorFactory` backends. +/// +/// The user's factory is invoked once per worker with a clone of the noise +/// model; per-shot seeding from global shot indices happens in the shared +/// schedule, exactly as for built-in backends. +struct CustomRunnerFactory { + factory: Arc, + num_qubits: usize, + noise: Option, +} + +impl ParallelQuantumRunnerFactory for CustomRunnerFactory { + fn create_runner(&self, seed: Option) -> QuantumRunner { + QuantumRunner::Custom( + self.factory + .create_runner(self.num_qubits, self.noise.clone(), seed), + ) + } +} + struct AdaptedQuantumEngineRunnerFactory where B: pecos_engines::QuantumEngineBuilder + Clone + 'static, @@ -3134,7 +3829,11 @@ fn build_parallel_execution_plan( ); factory.create_parallel_runner_factory(num_qubits) } - QuantumBackend::Custom(_) => return None, + QuantumBackend::Custom(factory) => Box::new(CustomRunnerFactory { + factory: Arc::clone(factory), + num_qubits, + noise, + }), }; Some(ParallelExecutionPlan { @@ -3144,7 +3843,11 @@ fn build_parallel_execution_plan( } impl Simulation { - /// Set the number of shots for the next run. + /// Override the number of shots for the next run. + /// + /// Rerun convenience: adjusts the shot count of the already-built + /// simulation without rebuilding. The sampling strategy (and its worker + /// count) is fixed at build time. pub fn shots(&mut self, shots: usize) -> &mut Self { self.tool.resource_mut::().shots = shots; self @@ -3162,29 +3865,104 @@ impl Simulation { /// after reconfiguring with [`shots()`](Self::shots) or [`seed()`](Self::seed). /// /// Execution strategy depends on the sampling strategy: - /// - `MonteCarlo { workers: 1 }`: Runs shots via the Tool (default) - /// - `MonteCarlo { workers: n }`: Parallelizes shots across n workers + /// - `MonteCarlo { workers: 1, .. }`: Runs shots via the Tool + /// - `MonteCarlo { workers: n, .. }`: Parallelizes shots across n workers /// - `ImportanceSampling`: Runs via the Tool with `ImportanceSamplingSimPlugin` + /// - `SubsetSimulation`: Runs the level-adaptive subset algorithm + /// directly; the estimate lands in [`SimulationResults::subset`] /// /// # Panics - /// Panics if parallel Monte Carlo is used without per-worker runner construction support. + /// + /// Panics if the parallel execution plan or subset spec is missing for + /// the corresponding strategy; `SimNeoBuilder::build()` validates both, + /// so it cannot happen for simulations constructed through the builder. pub fn run(&mut self) -> SimulationResults { let config = self.tool.resource::().clone(); // Dispatch based on sampling strategy match &self.sampling { - Sampling::MonteCarlo { workers } if *workers > 1 => { - let plan = self.parallel_plan.as_ref().unwrap_or_else(|| { - panic!( - "Parallel Monte Carlo requires per-worker runner construction support. \ - Dynamic programs need a command-source factory, and custom backends \ - need a quantum-runner factory. Remove .workers() / .auto_workers() \ - for single-worker execution, or use a backend/source path with \ - explicit per-worker construction." - ) - }); + Sampling::MonteCarlo { workers, .. } if *workers > 1 => { + let plan = self + .parallel_plan + .as_ref() + .expect("parallel plan validated at build time for workers > 1"); self.run_parallel(&config, plan, *workers) } + Sampling::ImportanceSampling { config: is_config } if is_config.workers > 1 => { + let spec = self + .is_parallel_spec + .as_ref() + .expect("parallel IS spec captured at build time"); + Self::run_parallel_importance(&config, is_config, spec) + } + Sampling::PathEnumeration { config: pe_config } => { + let spec = self + .path_spec + .as_ref() + .expect("path spec validated at build time"); + let mut explorer = PathExplorer::new(SparseStab::new(spec.num_qubits)); + + let mut seen = std::collections::BTreeSet::new(); + let mut outcomes = Vec::new(); + let mut weights = Vec::new(); + for forced_path in PathEnumerator::new(pe_config.max_measurements) { + let result = explorer.run_with_path(&spec.circuit, &forced_path); + // Different forced paths can realize the same actual path + // (deterministic measurements ignore forced bits); keep + // each distinct realized path once with its exact + // probability. + if seen.insert(result.path.signature().to_binary_string()) { + weights.push(result.path.probability()); + outcomes.push(result.outcomes); + } + } + + SimulationResults { + outcomes, + weights: Some(weights), + subset: None, + shots: None, + } + } + Sampling::SubsetSimulation { config: ss_config } => { + let spec = self + .subset_spec + .as_ref() + .expect("subset spec validated at build time"); + let score = ss_config + .score + .clone() + .expect("score fn validated at build time"); + let failure = ss_config + .failure + .clone() + .expect("failure fn validated at build time"); + // config.shots carries samples_per_level, so the rerun + // override Simulation::shots() applies to it naturally. + let subset_config = SubsetConfig { + samples_per_level: config.shots, + threshold_fraction: ss_config.threshold_fraction, + max_levels: ss_config.max_levels, + min_conditional_prob: ss_config.min_conditional_prob, + seed: config.seed, + }; + let noise = spec.noise.clone(); + let result = SubsetSimulation::new( + spec.circuit.clone(), + spec.num_qubits, + move |o: &MeasurementOutcomes| score(o), + move |o: &MeasurementOutcomes| failure(o), + ) + .with_noise_builder(move || noise.clone()) + .with_config(subset_config) + .run(); + SimulationResults { + outcomes: Vec::new(), + weights: None, + subset: Some(result), + shots: None, + } + } _ => { // Both MonteCarlo{workers:1} and ImportanceSampling run via the Tool. // IS uses ImportanceSamplingSimPlugin instead of UnifiedSimulationPlugin. @@ -3199,6 +3977,73 @@ impl Simulation { } } + /// Run importance sampling trials in parallel using rayon. + /// + /// Each worker builds its own boosted runner and processes a contiguous + /// range of global shot indices. Seeds derive from (base seed, global + /// shot index) alone — the same scheme as the sequential IS systems — + /// so outcomes and weights are identical for any worker count. + fn run_parallel_importance( + config: &SimConfig, + is_config: &ImportanceSamplingBuilder, + spec: &StaticCircuitSpec, + ) -> SimulationResults { + let shots = config.shots; + let num_workers = is_config.workers; + // Reseed per shot only when a base seed was configured; an + // unseeded run keeps each worker's entropy-seeded runner, matching + // the Monte Carlo path rather than forcing a deterministic stream. + let base_seed = config.seed; + + let shots_per_worker = distribute_shots(shots, num_workers); + let mut start_indices = vec![0usize; num_workers]; + for i in 1..num_workers { + start_indices[i] = start_indices[i - 1] + shots_per_worker[i - 1]; + } + + let per_worker: Vec<( + Vec, + Vec, + )> = (0..num_workers) + .into_par_iter() + .map(|worker_id| { + let worker_shots = shots_per_worker[worker_id]; + let mut outcomes = Vec::with_capacity(worker_shots); + let mut weights = Vec::with_capacity(worker_shots); + if worker_shots == 0 { + return (outcomes, weights); + } + + let mut runner = build_importance_runner(is_config, spec.num_qubits); + let start = start_indices[worker_id]; + for shot_index in start..start + worker_shots { + if let Some(base_seed) = base_seed { + seed_importance_runner(&mut runner, base_seed, shot_index); + } + let result = runner.run_shot_fresh(&spec.circuit); + outcomes.push(result.outcomes); + weights.push(result.weight); + } + (outcomes, weights) + }) + .collect(); + + // Flatten in worker order = global shot order. + let mut outcomes = Vec::with_capacity(shots); + let mut weights = Vec::with_capacity(shots); + for (o, w) in per_worker { + outcomes.extend(o); + weights.extend(w); + } + + SimulationResults { + outcomes, + weights: Some(weights), + subset: None, + shots: None, + } + } + /// Run shots in parallel using rayon (static circuits with built-in backends). /// /// Each worker gets its own `Resources` and runs the shared schedule, @@ -3266,12 +4111,39 @@ impl Simulation { }) .collect(); - // Flatten in deterministic order - let outcomes = all_results.into_iter().flat_map(|r| r.outcomes).collect(); + // Flatten in deterministic order, merging per-worker register shots + // when the source produced them. + let mut outcomes = Vec::new(); + let mut shots: Option = None; + for worker_results in all_results { + outcomes.extend(worker_results.outcomes); + if let Some(worker_shots) = worker_results.shots { + shots + .get_or_insert_with(pecos_results::ShotVec::new) + .shots + .extend(worker_shots.shots); + } + } + + // Cross-worker alignment: each worker's records are index-aligned + // (the post-shot debug-assert enforces it per worker), but merging + // a worker that produced shot records with one that did not would + // desync the flattened vectors. Guard the merged invariant too. + debug_assert!( + shots + .as_ref() + .is_none_or(|s| s.shots.len() == outcomes.len()), + "merged shot records and outcomes are misaligned ({} shot records vs {} \ + outcomes); all workers must agree on whether the source produces shot records", + shots.as_ref().map_or(0, |s| s.shots.len()), + outcomes.len(), + ); SimulationResults { outcomes, weights: None, + subset: None, + shots, } } @@ -3309,16 +4181,16 @@ impl Simulation { /// ## Static Circuit /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new() /// .pz(&[0]).h(&[0]).mz(&[0]) /// .build(); /// -/// let results = sim_neo(circuit) +/// let results = sim_neo(circuit).auto() /// .depolarizing(0.01) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -3327,7 +4199,7 @@ impl Simulation { /// ## QASM Program /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm = r#" @@ -3341,10 +4213,10 @@ impl Simulation { /// measure q[1] -> c[1]; /// "#; /// -/// let results = sim_neo(qasm) +/// let results = sim_neo(qasm).auto() /// .classical(qasm_engine()) /// .depolarizing(0.01) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -3353,12 +4225,12 @@ impl Simulation { /// ## Reusable Simulation /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let mut sim = sim_neo(circuit) -/// .shots(1000) +/// let mut sim = sim_neo(circuit).auto() +/// .sampling(monte_carlo(1000)) /// .build(); /// /// let results1 = sim.run(); @@ -3377,7 +4249,7 @@ pub fn sim_neo(input: I) -> SimNeoBuilder { /// # Example /// /// ```no_run -/// use pecos_neo::tool::sim_neo_builder; +/// use pecos_neo::tool::{monte_carlo, sim_neo_builder}; /// use pecos_qasm::qasm_engine; /// /// let qasm = r#" @@ -3394,7 +4266,7 @@ pub fn sim_neo(input: I) -> SimNeoBuilder { /// let results = sim_neo_builder() /// .with_engine(qasm_engine().qasm(qasm)) /// .depolarizing(0.01) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -3437,7 +4309,11 @@ mod tests { .mz(&[0]) .build(); - let mut sim = sim_neo(circuit).shots(10).seed(42).build(); + let mut sim = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .seed(42) + .build(); let results = sim.run(); @@ -3456,7 +4332,7 @@ mod tests { fn test_sim_neo_rerun() { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - let mut sim = sim_neo(circuit).shots(5).build(); + let mut sim = sim_neo(circuit).auto().sampling(monte_carlo(5)).build(); let results1 = sim.run(); assert_eq!(results1.len(), 5); @@ -3476,9 +4352,19 @@ mod tests { .build(); // Same seed should produce same results - let results1 = sim_neo(circuit.clone()).shots(20).seed(42).build().run(); + let results1 = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(20)) + .seed(42) + .build() + .run(); - let results2 = sim_neo(circuit).shots(20).seed(42).build().run(); + let results2 = sim_neo(circuit) + .auto() + .sampling(monte_carlo(20)) + .seed(42) + .build() + .run(); assert_eq!(results1.outcomes.len(), results2.outcomes.len()); for (o1, o2) in results1.outcomes.iter().zip(results2.outcomes.iter()) { @@ -3505,8 +4391,9 @@ mod tests { let noise = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.5)); let results = sim_neo(circuit) + .auto() .noise(noise) - .shots(100) + .sampling(monte_carlo(100)) .seed(42) .build() .run(); @@ -3542,15 +4429,17 @@ mod tests { let noise2 = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.5)); let results1 = sim_neo(circuit.clone()) + .auto() .noise(noise1) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .build() .run(); let results2 = sim_neo(circuit) + .auto() .noise(noise2) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .build() .run(); @@ -3571,8 +4460,9 @@ mod tests { // This uses the From impl for ComposableNoiseModel let results = sim_neo(circuit) + .auto() .noise(SingleQubitChannel::depolarizing(0.5)) - .shots(50) + .sampling(monte_carlo(50)) .seed(42) .build() .run(); @@ -3598,8 +4488,9 @@ mod tests { // Pass builder directly - no .build() needed! let results = sim_neo(circuit) + .auto() .noise(GeneralNoiseModelBuilder::new().with_p1(0.3)) - .shots(100) + .sampling(monte_carlo(100)) .seed(42) .build() .run(); @@ -3632,8 +4523,9 @@ mod tests { .build(); let results = sim_neo(circuit) + .auto() .depolarizing(0.2) // 20% on both 1Q and 2Q gates - .shots(100) + .sampling(monte_carlo(100)) .seed(42) .build() .run(); @@ -3663,8 +4555,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .noise(GeneralNoiseModelBuilder::new().with_p_meas_symmetric(0.15)) - .shots(200) + .sampling(monte_carlo(200)) .seed(42) .build() .run(); @@ -3693,8 +4586,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .noise(GeneralNoiseModelBuilder::new().with_p_prep(0.20)) - .shots(200) + .sampling(monte_carlo(200)) .seed(42) .build() .run(); @@ -3732,7 +4626,11 @@ mod tests { // .auto() should automatically select qasm_engine() // Using .run() shortcut (equivalent to .build().run()) - let results = sim_neo(qasm).auto().shots(10).seed(42).run(); + let results = sim_neo(qasm) + .auto() + .sampling(monte_carlo(10)) + .seed(42) + .run(); assert_eq!(results.len(), 10); @@ -3760,8 +4658,9 @@ mod tests { // Direct .run() without explicit .build() let results = sim_neo(qasm_source) + .auto() .classical(pecos_qasm::qasm_engine()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -3793,7 +4692,12 @@ mod tests { let program = pecos_programs::Program::Qasm(pecos_programs::Qasm::from_string(qasm_source)); // .auto() should detect Qasm variant and use qasm_engine() - let results = sim_neo(program).auto().shots(50).seed(42).build().run(); + let results = sim_neo(program) + .auto() + .sampling(monte_carlo(50)) + .seed(42) + .build() + .run(); assert_eq!(results.len(), 50); @@ -3815,7 +4719,11 @@ mod tests { .build(); // Use .workers() convenience method for Monte Carlo - let results = sim_neo(circuit).workers(4).shots(100).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(100).workers(4)) + .seed(42) + .run(); assert_eq!(results.len(), 100); @@ -3837,9 +4745,17 @@ mod tests { .mz(&[0]) .build(); - let results1 = sim_neo(circuit.clone()).workers(4).shots(50).seed(42).run(); + let results1 = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(50).workers(4)) + .seed(42) + .run(); - let results2 = sim_neo(circuit).workers(4).shots(50).seed(42).run(); + let results2 = sim_neo(circuit) + .auto() + .sampling(monte_carlo(50).workers(4)) + .seed(42) + .run(); assert_eq!(results1.outcomes.len(), results2.outcomes.len()); for (o1, o2) in results1.outcomes.iter().zip(results2.outcomes.iter()) { @@ -3853,15 +4769,12 @@ mod tests { #[test] fn test_sim_neo_sampling_explicit() { - // Test explicit sampling configuration - use super::Sampling; - + // Test explicit sampling configuration with workers on the builder let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - // Use explicit Sampling enum let results = sim_neo(circuit) - .sampling(Sampling::monte_carlo(2)) - .shots(20) + .auto() + .sampling(monte_carlo(20).workers(2)) .seed(42) .run(); @@ -3876,68 +4789,707 @@ mod tests { } #[test] - fn test_sim_neo_single_worker_matches_parallel() { - // Critical test: 1 worker and multiple workers should produce identical - // results with the same seed (they use the same per-shot seeding scheme) - let circuit = CommandBuilder::new() - .pz(&[0]) - .h(&[0]) // Superposition - outcome depends on RNG - .mz(&[0]) - .build(); - - // Run with default (1 worker) - let single_results = sim_neo(circuit.clone()).shots(50).seed(42).run(); + fn test_sim_neo_sampling_order_independent() { + // Regression for the old top-level .workers() footgun: builder calls + // must commute. .sampling() before or after other config gives the + // same results. + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - // Run with parallel Monte Carlo sampling (4 workers) - let parallel_results = sim_neo(circuit).workers(4).shots(50).seed(42).run(); + let r1 = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(30)) + .seed(7) + .run(); + let r2 = sim_neo(circuit) + .auto() + .seed(7) + .sampling(monte_carlo(30)) + .run(); - // Results should be identical - assert_eq!( - single_results.outcomes.len(), - parallel_results.outcomes.len() - ); - for (i, (single, par)) in single_results - .outcomes - .iter() - .zip(parallel_results.outcomes.iter()) - .enumerate() - { - assert_eq!( - single.get_bit(QubitId(0)), - par.get_bit(QubitId(0)), - "Single-worker and parallel should produce identical results at shot {i}" - ); + assert_eq!(r1.outcomes.len(), r2.outcomes.len()); + for (o1, o2) in r1.outcomes.iter().zip(r2.outcomes.iter()) { + assert_eq!(o1.get_bit(QubitId(0)), o2.get_bit(QubitId(0))); } } #[test] - fn test_sim_neo_noisy_single_worker_matches_parallel() { - // Critical test: parallel noisy execution should produce identical results - // to single-worker noisy execution with the same seed. - let circuit = CommandBuilder::new() - .pz(&[0]) - .h(&[0]) - .z(&[0]) // Trigger single-qubit noise - .mz(&[0]) - .build(); + #[should_panic(expected = "No sampling strategy set")] + fn test_sim_neo_missing_sampling_is_build_error() { + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit).auto().build(); + } - let noise_single = - ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.3)); - let noise_par = - ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.3)); + #[test] + #[should_panic(expected = "No quantum backend set")] + fn test_sim_neo_missing_quantum_backend_is_build_error() { + // Explicit-by-default: no silent SparseStab. Either .quantum(..) or + // .auto() must be called. + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit).sampling(monte_carlo(10)).build(); + } + + #[test] + fn test_sim_neo_auto_selects_backend_for_static_circuit() { + // .auto() opts into automatic backend selection (SparseStab) for + // static circuits, which previously rejected .auto() entirely. + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .seed(1) + .run(); + assert_eq!(results.len(), 10); + for outcome in &results.outcomes { + assert!(outcome.get_bit(QubitId(0)).unwrap()); + } + } + + #[test] + fn test_sim_neo_explicit_quantum_overrides_auto() { + // .auto() plus explicit .quantum() is allowed; the explicit choice + // wins regardless of call order. + let circuit = CommandBuilder::new() + .pz(&[0]) + .x(&[0]) + .t(&[0]) + .mz(&[0]) + .build(); + // T gate requires the state-vector backend; if auto's SparseStab + // choice won, this would fail to execute. + let results = sim_neo(circuit) + .auto() + .quantum(state_vector()) + .sampling(monte_carlo(5)) + .seed(1) + .run(); + assert_eq!(results.len(), 5); + } + + #[test] + #[should_panic(expected = ".shots() cannot be combined with")] + fn test_sim_neo_shots_conflicts_with_sampling() { + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .shots(20) + .build(); + } + + #[test] + #[should_panic(expected = "deprecated .workers() cannot be combined")] + fn test_sim_neo_legacy_workers_conflicts_with_sampling() { + // The old footgun: .sampling(importance_sampling(..)).workers(n) + // silently discarded the importance-sampling config. Now it fails + // loudly at build time. + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + #[allow(deprecated)] + let _ = sim_neo(circuit) + .auto() + .sampling(importance_sampling(10)) + .workers(4) + .build(); + } + + #[test] + #[should_panic( + expected = "Parallel Monte Carlo (workers > 1) requires per-worker construction" + )] + fn test_sim_neo_parallel_dynamic_source_fails_at_build_not_run() { + // The parallel-incapable combination must be rejected by .build(), + // before any shot executes. + let _ = sim_neo(deterministic_conditional_program()) + .auto() + .sampling(monte_carlo(2).workers(2)) + .build(); + } + + #[test] + fn test_sim_neo_shots_shortcut_matches_sampling() { + // .shots(n) must behave exactly like .sampling(monte_carlo(n)) -- it is + // the blessed shorthand for that common case. + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + + let shortcut = sim_neo(circuit.clone()).auto().shots(40).seed(11).run(); + let explicit = sim_neo(circuit) + .auto() + .sampling(monte_carlo(40)) + .seed(11) + .run(); + + assert_eq!(shortcut.outcomes.len(), 40); + assert_eq!(shortcut.outcomes.len(), explicit.outcomes.len()); + for (o1, o2) in shortcut.outcomes.iter().zip(explicit.outcomes.iter()) { + assert_eq!(o1.get_bit(QubitId(0)), o2.get_bit(QubitId(0))); + } + } + + #[test] + fn test_sim_neo_shots_with_deprecated_workers_still_parallel() { + // Blessed .shots(m) combined with the deprecated .workers(n) forwarder + // maps onto MonteCarlo { shots: m, workers: n } and still runs. + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + #[allow(deprecated)] + let results = sim_neo(circuit).auto().workers(2).shots(30).seed(5).run(); + + assert_eq!(results.len(), 30); + for outcome in &results.outcomes { + assert!(outcome.get_bit(QubitId(0)).unwrap()); + } + } + + #[test] + fn test_sim_neo_importance_sampling_shot_count_on_builder() { + // importance_sampling(shots) drives the trial count directly. + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + + let results = sim_neo(circuit) + .auto() + .sampling(importance_sampling(35).with_uniform_error(0.01)) + .seed(3) + .run(); + + assert_eq!(results.len(), 35); + assert!(results.has_weights()); + } + + #[test] + fn test_sim_neo_importance_sampling_parallel_matches_sequential() { + // Per-shot seeding from global indices: any worker count gives + // identical outcomes AND weights. + let circuit = CommandBuilder::new() + .pz(&[0, 1]) + .h(&[0]) + .cx(&[(0, 1)]) + .mz(&[0, 1]) + .build(); + let run = |workers: usize| { + sim_neo(circuit.clone()) + .auto() + .sampling( + importance_sampling(60) + .with_uniform_error(0.01) + .with_boost(10.0) + .workers(workers), + ) + .seed(42) + .run() + }; + + let sequential = run(1); + let parallel = run(4); + + assert_eq!(sequential.outcomes.len(), 60); + assert_eq!(sequential.outcomes.len(), parallel.outcomes.len()); + for (i, (s, p)) in sequential + .outcomes + .iter() + .zip(parallel.outcomes.iter()) + .enumerate() + { + assert_eq!( + s.get_bit(QubitId(0)), + p.get_bit(QubitId(0)), + "Shot {i} qubit 0 should match" + ); + assert_eq!( + s.get_bit(QubitId(1)), + p.get_bit(QubitId(1)), + "Shot {i} qubit 1 should match" + ); + } + + let sw = sequential.weights.as_ref().unwrap(); + let pw = parallel.weights.as_ref().unwrap(); + assert_eq!(sw.len(), pw.len()); + for (i, (a, b)) in sw.iter().zip(pw.iter()).enumerate() { + assert!( + (a.weight() - b.weight()).abs() < 1e-12, + "Weight at shot {i} should match" + ); + } + } + + #[test] + fn test_sim_neo_importance_sampling_parallel_deterministic() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let run = || { + sim_neo(circuit.clone()) + .auto() + .sampling(importance_sampling(30).with_uniform_error(0.01).workers(3)) + .seed(5) + .run() + }; + let r1 = run(); + let r2 = run(); + for (o1, o2) in r1.outcomes.iter().zip(r2.outcomes.iter()) { + assert_eq!(o1.get_bit(QubitId(0)), o2.get_bit(QubitId(0))); + } + } + + #[test] + fn test_sim_neo_clifford_angle_rotation_on_stabilizer_backend() { + // QASM compiles s -> u1(pi/2) -> rz(pi/2); Clifford backends must + // execute Clifford-angle rotations via the CliffordRotation + // decomposition instead of failing with NoDecomposition. + // h . rz(pi/2) . rz(pi/2) . h = h z h = x, so the outcome is + // deterministically 1. + use pecos_core::Angle64; + let circuit = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) + .rz(&[0], Angle64::QUARTER_TURN) + .rz(&[0], Angle64::QUARTER_TURN) + .h(&[0]) + .mz(&[0]) + .build(); + + let results = sim_neo(circuit) + .quantum(sparse_stab()) + .sampling(monte_carlo(10)) + .seed(1) + .run(); + + for outcome in &results.outcomes { + assert_eq!( + outcome.get_bit(QubitId(0)), + Some(true), + "h z h = x must deterministically flip" + ); + } + } + + // --- Shot/ShotVec Production Tests --- + + #[test] + fn test_sim_neo_static_circuit_shot_vec_synthesis() { + let circuit = CommandBuilder::new() + .pz(&[0, 1]) + .x(&[0]) + .mz(&[0, 1]) + .build(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(3)) + .seed(1) + .run(); + + assert!( + results.shots.is_none(), + "Static circuits have no register data" + ); + + // Without a map: single "meas" register, bits in ascending qubit order. + let synthesized = results.to_shot_vec(None); + assert_eq!(synthesized.shots.len(), 3); + for shot in &synthesized.shots { + assert_eq!(shot.data["meas"].to_bitstring().unwrap(), "10"); + } + + // With a map: one BitVec register per name. + let mut map = RegisterMap::new(); + map.add_register("a", &[QubitId(0)]); + map.add_register("b", &[QubitId(1)]); + let named = results.to_shot_vec(Some(&map)); + for shot in &named.shots { + assert_eq!(shot.data["a"].to_bitstring().unwrap(), "1"); + assert_eq!(shot.data["b"].to_bitstring().unwrap(), "0"); + } + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_produces_register_shots() { + // The classical engine's named cregs flow through the adapter into + // SimulationResults::shots — including the feedback-conditioned bit. + let program = pecos_programs::Qasm::from_string(deterministic_conditional_qasm()); + let results = sim_neo(program) + .auto() + .quantum(sparse_stab()) + .sampling(monte_carlo(5)) + .seed(42) + .run(); + + let shots = results + .shots + .as_ref() + .expect("classical engines produce register shots"); + assert_eq!(shots.shots.len(), 5); + for shot in &shots.shots { + assert_eq!(shot.data["c"].to_bitstring().unwrap(), "11"); + } + // to_shot_vec returns the engine-produced registers as-is. + assert_eq!(results.to_shot_vec(None).shots.len(), 5); + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_parallel_register_shots() { + let program = pecos_programs::Qasm::from_string(deterministic_conditional_qasm()); + let results = sim_neo(program) + .auto() + .quantum(sparse_stab()) + .sampling(monte_carlo(6).workers(2)) + .seed(42) + .run(); + + let shots = results + .shots + .as_ref() + .expect("parallel adapter runs merge register shots"); + assert_eq!(shots.shots.len(), 6); + for shot in &shots.shots { + assert_eq!(shot.data["c"].to_bitstring().unwrap(), "11"); + } + } + + // --- Path Enumeration Strategy Tests --- + + #[test] + fn test_sim_neo_path_enumeration_bell_pair() { + // H + CX: first measurement is random (two branches), second is + // deterministic given the first. Exactly two paths, p = 0.5 each, + // with perfectly correlated outcomes. + let circuit = CommandBuilder::new() + .pz(&[0, 1]) + .h(&[0]) + .cx(&[(0, 1)]) + .mz(&[0, 1]) + .build(); + + let results = sim_neo(circuit) + .quantum(sparse_stab()) + .sampling(path_enumeration(1)) + .run(); + + assert_eq!(results.outcomes.len(), 2, "Two measurement branches"); + let weights = results.weights.as_ref().unwrap(); + let total: f64 = weights + .iter() + .map(crate::sampling::weight::SampleWeight::weight) + .sum(); + assert!( + (total - 1.0).abs() < 1e-12, + "Complete enumeration sums to 1" + ); + for (outcome, weight) in results.outcomes.iter().zip(weights) { + assert!((weight.weight() - 0.5).abs() < 1e-12); + assert_eq!( + outcome.get_bit(QubitId(0)), + outcome.get_bit(QubitId(1)), + "Bell pair outcomes must be correlated" + ); + } + } + + #[test] + fn test_sim_neo_path_enumeration_dedupes_deterministic_circuit() { + // X then measure: fully deterministic, one realized path with p = 1 + // even though 2^2 forced paths are enumerated. + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let results = sim_neo(circuit).auto().sampling(path_enumeration(2)).run(); + + assert_eq!(results.outcomes.len(), 1, "One distinct path"); + let weights = results.weights.as_ref().unwrap(); + assert!((weights[0].weight() - 1.0).abs() < 1e-12); + assert_eq!(results.outcomes[0].get_bit(QubitId(0)), Some(true)); + } + + #[test] + fn test_sim_neo_path_enumeration_three_qubit_uniform() { + // Three independent H measurements: 8 paths, p = 1/8 each. + let results = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling(path_enumeration(3)) + .run(); + + assert_eq!(results.outcomes.len(), 8); + let weights = results.weights.as_ref().unwrap(); + let total: f64 = weights + .iter() + .map(crate::sampling::weight::SampleWeight::weight) + .sum(); + assert!((total - 1.0).abs() < 1e-12); + for weight in weights { + assert!((weight.weight() - 0.125).abs() < 1e-12); + } + } + + #[test] + #[should_panic(expected = "remove .noise()")] + fn test_sim_neo_path_enumeration_rejects_noise() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit) + .auto() + .noise(SingleQubitChannel::depolarizing(0.1)) + .sampling(path_enumeration(1)) + .build(); + } + + #[test] + #[should_panic(expected = "Path enumeration currently supports only the sparse_stab() backend")] + fn test_sim_neo_path_enumeration_rejects_state_vector_backend() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit) + .quantum(state_vector()) + .sampling(path_enumeration(1)) + .build(); + } + + #[test] + #[should_panic(expected = "more than 16M paths")] + fn test_sim_neo_path_enumeration_rejects_huge_enumeration() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit) + .auto() + .sampling(path_enumeration(25)) + .build(); + } + + // --- Subset Simulation Strategy Tests --- + + fn three_qubit_h_circuit() -> CommandQueue { + CommandBuilder::new() + .pz(&[0, 1, 2]) + .h(&[0, 1, 2]) + .mz(&[0, 1, 2]) + .build() + } + + fn count_ones(outcomes: &MeasurementOutcomes) -> f64 { + outcomes.iter().filter(|m| m.outcome).count() as f64 + } + + fn all_ones(outcomes: &MeasurementOutcomes) -> bool { + outcomes.iter().count() > 0 && outcomes.iter().all(|m| m.outcome) + } + + #[test] + fn test_sim_neo_subset_simulation_estimates_known_probability() { + // Three H gates: P(all three measure 1) = 1/8. + let results = sim_neo(three_qubit_h_circuit()) + .quantum(sparse_stab()) + .sampling(subset_simulation(2000).score(count_ones).failure(all_ones)) + .seed(42) + .run(); + + assert!(results.outcomes.is_empty()); + let subset = results.subset.expect("subset strategy returns an estimate"); + let p = subset.probability(); + assert!( + (0.08..=0.20).contains(&p), + "Expected estimate near 1/8, got {p:.4}" + ); + assert!(subset.total_samples >= 2000); + } + + #[test] + fn test_sim_neo_subset_simulation_certain_event() { + // X gate makes the failure event certain. + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let results = sim_neo(circuit) + .auto() + .sampling(subset_simulation(200).score(count_ones).failure(all_ones)) + .seed(7) + .run(); + + let subset = results.subset.expect("subset estimate"); + assert!( + (subset.probability() - 1.0).abs() < 1e-9, + "Certain event should estimate ~1.0, got {}", + subset.probability() + ); + } + + #[test] + fn test_sim_neo_subset_simulation_with_noise() { + // Depolarizing(0.3) after Z: P(flip) = 2/3 * 0.3 = 0.2. + let circuit = CommandBuilder::new().pz(&[0]).z(&[0]).mz(&[0]).build(); + let noise = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.3)); + + let results = sim_neo(circuit) + .quantum(sparse_stab()) + .noise(noise) + .sampling(subset_simulation(2000).score(count_ones).failure(all_ones)) + .seed(11) + .run(); + + let p = results.subset.expect("subset estimate").probability(); + assert!( + (0.12..=0.28).contains(&p), + "Expected estimate near 0.2, got {p:.4}" + ); + } + + #[test] + fn test_sim_neo_subset_simulation_deterministic() { + let run = || { + sim_neo(three_qubit_h_circuit()) + .auto() + .sampling(subset_simulation(500).score(count_ones).failure(all_ones)) + .seed(99) + .run() + .subset + .expect("subset estimate") + .probability() + }; + assert!((run() - run()).abs() < 1e-15, "Same seed, same estimate"); + } + + #[test] + #[should_panic(expected = "requires both .score(..) and .failure(..)")] + fn test_sim_neo_subset_simulation_missing_fns_is_build_error() { + let _ = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling(subset_simulation(100)) + .build(); + } + + #[test] + #[should_panic(expected = "Subset simulation requires a static circuit")] + fn test_sim_neo_subset_simulation_rejects_dynamic_source() { + let _ = sim_neo(deterministic_conditional_program()) + .auto() + .sampling(subset_simulation(100).score(count_ones).failure(all_ones)) + .build(); + } + + #[test] + #[should_panic(expected = "supports only the sparse_stab() backend")] + fn test_sim_neo_subset_simulation_rejects_state_vector_backend() { + let _ = sim_neo(three_qubit_h_circuit()) + .quantum(state_vector()) + .sampling(subset_simulation(100).score(count_ones).failure(all_ones)) + .build(); + } + + #[test] + #[should_panic(expected = "biased upward")] + fn test_sim_neo_subset_multilevel_without_opt_in_is_build_error() { + // max_levels > 1 engages the biased multi-level estimator and must + // be refused unless explicitly acknowledged. + let _ = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling( + subset_simulation(100) + .score(count_ones) + .failure(all_ones) + .max_levels(5), + ) + .build(); + } + + #[test] + fn test_sim_neo_subset_multilevel_runs_with_opt_in() { + // With the explicit acknowledgment, the multi-level path runs. + let results = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling( + subset_simulation(500) + .score(count_ones) + .failure(all_ones) + .max_levels(5) + .allow_biased_multilevel(), + ) + .seed(42) + .run(); + assert!( + results.subset.is_some(), + "multi-level opt-in must produce an estimate" + ); + } + + #[test] + fn test_sim_neo_subset_default_is_single_level() { + // The default (no .max_levels) is a single unbiased level: exactly + // one level in the result. + let results = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling(subset_simulation(500).score(count_ones).failure(all_ones)) + .seed(42) + .run(); + let subset = results.subset.expect("subset estimate"); + assert_eq!( + subset.levels.len(), + 1, + "default subset_simulation must run a single (unbiased) level" + ); + } + + #[test] + fn test_sim_neo_single_worker_matches_parallel() { + // Critical test: 1 worker and multiple workers should produce identical + // results with the same seed (they use the same per-shot seeding scheme) + let circuit = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) // Superposition - outcome depends on RNG + .mz(&[0]) + .build(); + + // Run with default (1 worker) + let single_results = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(50)) + .seed(42) + .run(); + + // Run with parallel Monte Carlo sampling (4 workers) + let parallel_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(50).workers(4)) + .seed(42) + .run(); + + // Results should be identical + assert_eq!( + single_results.outcomes.len(), + parallel_results.outcomes.len() + ); + for (i, (single, par)) in single_results + .outcomes + .iter() + .zip(parallel_results.outcomes.iter()) + .enumerate() + { + assert_eq!( + single.get_bit(QubitId(0)), + par.get_bit(QubitId(0)), + "Single-worker and parallel should produce identical results at shot {i}" + ); + } + } + + #[test] + fn test_sim_neo_noisy_single_worker_matches_parallel() { + // Critical test: parallel noisy execution should produce identical results + // to single-worker noisy execution with the same seed. + let circuit = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) + .z(&[0]) // Trigger single-qubit noise + .mz(&[0]) + .build(); + + let noise_single = + ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.3)); + let noise_par = + ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.3)); // Run with single worker (default) let single_results = sim_neo(circuit.clone()) + .auto() .noise(noise_single) - .shots(50) + .sampling(monte_carlo(50)) .seed(42) .run(); // Run with parallel Monte Carlo sampling let parallel_results = sim_neo(circuit) + .auto() .noise(noise_par) - .workers(4) - .shots(50) + .sampling(monte_carlo(50).workers(4)) .seed(42) .run(); @@ -3975,16 +5527,16 @@ mod tests { let noise2 = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.3)); let results1 = sim_neo(circuit.clone()) + .auto() .noise(noise1) - .workers(4) - .shots(50) + .sampling(monte_carlo(50).workers(4)) .seed(42) .run(); let results2 = sim_neo(circuit) + .auto() .noise(noise2) - .workers(4) - .shots(50) + .sampling(monte_carlo(50).workers(4)) .seed(42) .run(); @@ -4011,7 +5563,7 @@ mod tests { let results = sim_neo(circuit) .quantum(sparse_stab()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -4034,7 +5586,7 @@ mod tests { let results = sim_neo(circuit) .quantum(stabilizer()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -4060,7 +5612,7 @@ mod tests { let results = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) - .shots(12) + .sampling(monte_carlo(12)) .seed(42) .run(); @@ -4087,7 +5639,7 @@ mod tests { let results = sim_neo(circuit) .quantum(pecos_engines::state_vector()) - .shots(8) + .sampling(monte_carlo(8)) .seed(123) .run(); @@ -4110,7 +5662,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .noise(noise) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4124,8 +5676,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .noise(noise) - .workers(2) - .shots(2) + .sampling(monte_carlo(2).workers(2)) .run(); } #[test] @@ -4140,7 +5691,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .gate_definitions(GateDefinitions::new()) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4153,7 +5704,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .max_decomp_depth(20) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4171,7 +5722,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .gate_overrides(overrides) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4186,7 +5737,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .event_handlers(handlers) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4195,8 +5746,7 @@ mod tests { let results = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) - .workers(2) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4217,8 +5767,7 @@ mod tests { let results = sim_neo(circuit) .quantum(pecos_engines::state_vector()) - .workers(2) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4259,7 +5808,7 @@ mod tests { fn test_sim_neo_dynamic_command_source_native_stabilizer() { let results = sim_neo(deterministic_conditional_program()) .quantum(stabilizer()) - .shots(6) + .sampling(monte_carlo(6)) .seed(42) .run(); @@ -4275,7 +5824,7 @@ mod tests { fn test_sim_neo_dynamic_command_source_rerun() { let mut sim = sim_neo(deterministic_conditional_program()) .quantum(stabilizer()) - .shots(2) + .sampling(monte_carlo(2)) .seed(42) .build(); @@ -4301,7 +5850,7 @@ mod tests { let results = sim_neo(deterministic_conditional_qasm()) .classical(pecos_qasm::qasm_engine()) .quantum(stabilizer()) - .shots(6) + .sampling(monte_carlo(6)) .seed(42) .run(); @@ -4316,7 +5865,7 @@ mod tests { fn test_sim_neo_dynamic_command_source_quantum_engine_adapter() { let results = sim_neo(deterministic_conditional_program()) .quantum(pecos_engines::stabilizer()) - .shots(6) + .sampling(monte_carlo(6)) .seed(42) .run(); @@ -4334,7 +5883,7 @@ mod tests { let results = sim_neo(deterministic_conditional_qasm()) .classical(pecos_qasm::qasm_engine()) .quantum(pecos_engines::stabilizer()) - .shots(6) + .sampling(monte_carlo(6)) .seed(42) .run(); @@ -4352,8 +5901,7 @@ mod tests { let results = sim_neo(deterministic_conditional_qasm()) .classical(pecos_qasm::qasm_engine()) .quantum(stabilizer()) - .workers(2) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4371,8 +5919,7 @@ mod tests { let results = sim_neo(deterministic_conditional_qasm()) .classical(pecos_qasm::qasm_engine()) .quantum(pecos_engines::stabilizer()) - .workers(2) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4390,9 +5937,8 @@ mod tests { let program = pecos_programs::Qasm::from_string(deterministic_conditional_qasm()); let results = sim_neo(program) .auto() - .workers(2) .quantum(pecos_engines::stabilizer()) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4405,13 +5951,12 @@ mod tests { } #[test] #[should_panic( - expected = "Parallel Monte Carlo requires per-worker runner construction support" + expected = "Parallel Monte Carlo (workers > 1) requires per-worker construction" )] fn test_sim_neo_dynamic_command_source_quantum_engine_adapter_rejects_parallel_workers() { let _ = sim_neo(deterministic_conditional_program()) .quantum(pecos_engines::stabilizer()) - .workers(2) - .shots(2) + .sampling(monte_carlo(2).workers(2)) .run(); } @@ -4424,7 +5969,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -4448,13 +5993,13 @@ mod tests { // Test sparse_stab determinism let sparse1 = sim_neo(circuit.clone()) .quantum(sparse_stab()) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); let sparse2 = sim_neo(circuit.clone()) .quantum(sparse_stab()) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); @@ -4469,13 +6014,13 @@ mod tests { // Test state_vector determinism let sv1 = sim_neo(circuit.clone()) .quantum(state_vector()) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); let sv2 = sim_neo(circuit) .quantum(state_vector()) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); @@ -4497,8 +6042,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) - .workers(4) - .shots(100) + .sampling(monte_carlo(100).workers(4)) .seed(42) .run(); @@ -4520,8 +6064,8 @@ mod tests { use super::importance_sampling; let _ = sim_neo(deterministic_conditional_program()) - .sampling(importance_sampling()) - .shots(1) + .auto() + .sampling(importance_sampling(1)) .run(); } @@ -4537,14 +6081,14 @@ mod tests { .build(); let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(100) .with_p1(0.01) .with_p2(0.02) .with_p_meas(0.01) .with_boost(5.0), ) - .shots(100) .seed(42) .run(); @@ -4566,12 +6110,12 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(50) .with_uniform_error(0.01) .with_boost(10.0), ) - .shots(50) .seed(123) .run(); @@ -4593,12 +6137,12 @@ mod tests { // Run with importance sampling (boosting noise that doesn't affect this test) let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(2000) .with_uniform_error(0.001) - .with_boost(100.0), - ) // Very aggressive boost - .shots(2000) + .with_boost(100.0), // Very aggressive boost + ) .seed(42) .run(); @@ -4627,21 +6171,17 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - let is_builder = importance_sampling() + let is_builder = importance_sampling(20) .with_uniform_error(0.01) .with_boost(10.0); let results1 = sim_neo(circuit.clone()) + .auto() .sampling(is_builder.clone()) - .shots(20) .seed(42) .run(); - let results2 = sim_neo(circuit) - .sampling(is_builder) - .shots(20) - .seed(42) - .run(); + let results2 = sim_neo(circuit).auto().sampling(is_builder).seed(42).run(); assert_eq!(results1.outcomes.len(), results2.outcomes.len()); for (i, (o1, o2)) in results1 @@ -4683,14 +6223,14 @@ mod tests { .build(); let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(100) .with_p1(0.001) .with_p2(0.01) .with_p_meas(0.001) .with_boost(10.0), ) - .shots(100) .seed(42) .run(); @@ -4706,12 +6246,12 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(500) .with_uniform_error(0.01) .with_boost(10.0), ) - .shots(500) .seed(42) .run(); @@ -4747,7 +6287,7 @@ mod tests { let results = sim_neo(circuit) .quantum(custom_backend(SparseStab::new)) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -4774,13 +6314,13 @@ mod tests { let builtin_results = sim_neo(circuit.clone()) .quantum(sparse_stab()) - .shots(50) + .sampling(monte_carlo(50)) .seed(42) .run(); let custom_results = sim_neo(circuit) .quantum(custom_backend(SparseStab::new)) - .shots(50) + .sampling(monte_carlo(50)) .seed(42) .run(); @@ -4815,7 +6355,7 @@ mod tests { let results = sim_neo(circuit) .quantum(custom_backend(SparseStab::new)) .noise(noise) - .shots(100) + .sampling(monte_carlo(100)) .seed(42) .build() .run(); @@ -4841,13 +6381,13 @@ mod tests { let results1 = sim_neo(circuit.clone()) .quantum(custom_backend(SparseStab::new)) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); let results2 = sim_neo(circuit) .quantum(custom_backend(SparseStab::new)) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); @@ -4860,6 +6400,68 @@ mod tests { } } + #[test] + fn test_custom_backend_parallel_matches_sequential() { + // The factory builds one runner per worker; per-shot seeding from + // global shot indices makes results identical for any worker count. + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let run = |workers: usize| { + sim_neo(circuit.clone()) + .quantum(custom_backend(SparseStab::new)) + .sampling(monte_carlo(50).workers(workers)) + .seed(42) + .run() + }; + + let sequential = run(1); + let parallel = run(4); + + assert_eq!(sequential.outcomes.len(), 50); + assert_eq!(sequential.outcomes.len(), parallel.outcomes.len()); + for (i, (s, p)) in sequential + .outcomes + .iter() + .zip(parallel.outcomes.iter()) + .enumerate() + { + assert_eq!( + s.get_bit(QubitId(0)), + p.get_bit(QubitId(0)), + "Shot {i} should match across worker counts" + ); + } + } + + #[test] + fn test_custom_backend_parallel_with_noise() { + // Noise is cloned per worker; results stay worker-count invariant. + let circuit = CommandBuilder::new().pz(&[0]).z(&[0]).mz(&[0]).build(); + let run = |workers: usize| { + sim_neo(circuit.clone()) + .quantum(custom_backend(SparseStab::new)) + .noise(SingleQubitChannel::depolarizing(0.3)) + .sampling(monte_carlo(40).workers(workers)) + .seed(7) + .run() + }; + + let sequential = run(1); + let parallel = run(3); + + for (i, (s, p)) in sequential + .outcomes + .iter() + .zip(parallel.outcomes.iter()) + .enumerate() + { + assert_eq!( + s.get_bit(QubitId(0)), + p.get_bit(QubitId(0)), + "Noisy shot {i} should match across worker counts" + ); + } + } + #[test] fn test_custom_backend_state_vector() { // Verify StateVec also works via custom_backend @@ -4867,7 +6469,7 @@ mod tests { let results = sim_neo(circuit) .quantum(custom_backend(StateVec::new)) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -4891,7 +6493,11 @@ mod tests { let mut reg = RegisterMap::new(); reg.add_register("c", &[QubitId(0)]); - let results = sim_neo(circuit).shots(200).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(200)) + .seed(42) + .run(); let counts = results.register_counts(®, "c"); // Should have entries for both [false] and [true] @@ -4919,7 +6525,11 @@ mod tests { let mut reg = RegisterMap::new(); reg.add_register("c", &[QubitId(0), QubitId(1)]); - let results = sim_neo(circuit).shots(100).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(100)) + .seed(42) + .run(); let counts = results.register_counts(®, "c"); // Bell state: only |00> and |11> should appear @@ -4945,7 +6555,11 @@ mod tests { reg.add_register("a", &[QubitId(0)]); reg.add_register("b", &[QubitId(1)]); - let results = sim_neo(circuit).shots(5).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(5)) + .seed(42) + .run(); let columns = results.as_register_columns(®); assert_eq!(columns.len(), 2); @@ -4968,7 +6582,11 @@ mod tests { let mut reg = RegisterMap::new(); reg.add_register("missing", &[QubitId(5)]); // never measured - let results = sim_neo(circuit).shots(10).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .seed(42) + .run(); let counts = results.register_counts(®, "missing"); assert!( @@ -4990,8 +6608,9 @@ mod tests { let defs = GateDefinitions::new(); let results = sim_neo(circuit) + .auto() .gate_definitions(defs) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5018,7 +6637,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) .gate_definitions(defs) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5046,7 +6665,7 @@ mod tests { // This would fail with ProgramRunner::new() (Clifford-only) let results = sim_neo(circuit) .quantum(state_vector()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5077,7 +6696,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5104,8 +6723,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .build() .run(); @@ -5126,8 +6744,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .max_decomp_depth(20) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5148,9 +6767,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .max_decomp_depth(20) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .build() .run(); @@ -5175,7 +6794,7 @@ mod tests { let results = sim_neo(circuit) .quantum(custom_backend_with_rotations(StateVec::new)) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5206,8 +6825,9 @@ mod tests { .build(); let results = sim_neo(circuit) + .auto() .gate_overrides(overrides) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5235,9 +6855,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .gate_overrides(overrides) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .build() .run(); @@ -5266,7 +6886,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) .gate_overrides(overrides) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5290,10 +6910,11 @@ mod tests { let overrides = GateOverrides::::new().register(gates::X, |_sim, _angles, _qubits| true); - // SparseStab is the default backend -- StateVec overrides should panic + // .auto() selects SparseStab -- StateVec overrides should panic sim_neo(CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build()) + .auto() .gate_overrides(overrides) - .shots(1) + .sampling(monte_carlo(1)) .seed(42) .build() .run(); @@ -5311,7 +6932,7 @@ mod tests { sim_neo(CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build()) .quantum(state_vector()) .gate_overrides(overrides) - .shots(1) + .sampling(monte_carlo(1)) .seed(42) .build() .run(); @@ -5334,8 +6955,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .gate_overrides(overrides) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5358,9 +6980,9 @@ mod tests { let defs = GateDefinitions::new(); let results = sim_neo(circuit) + .auto() .gate_definitions(defs) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .build() .run(); diff --git a/exp/pecos-neo/tests/event_handlers_test.rs b/exp/pecos-neo/tests/event_handlers_test.rs index 080ac2b9f..8df4fcd62 100644 --- a/exp/pecos-neo/tests/event_handlers_test.rs +++ b/exp/pecos-neo/tests/event_handlers_test.rs @@ -14,7 +14,7 @@ use pecos_core::impl_signal; use pecos_neo::prelude::*; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -36,8 +36,9 @@ fn event_handlers_on_before_gate_fires_through_sim_neo() { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -59,9 +60,9 @@ fn event_handlers_parallel_workers_fire_per_worker() { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .workers(2) - .shots(20) + .sampling(monte_carlo(20).workers(2)) .seed(42) .run(); @@ -79,12 +80,17 @@ fn event_handlers_empty_is_noop() { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results_with = sim_neo(circuit.clone()) + .auto() .event_handlers(handlers) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); - let results_without = sim_neo(circuit).shots(10).seed(42).run(); + let results_without = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .seed(42) + .run(); assert_eq!(results_with.len(), results_without.len()); for (a, b) in results_with @@ -128,8 +134,9 @@ fn event_handlers_multiple_handler_types() { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .shots(5) + .sampling(monte_carlo(5)) .seed(42) .run(); @@ -178,8 +185,9 @@ fn signal_handler_fires_through_sim_neo() { .build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .shots(5) + .sampling(monte_carlo(5)) .seed(42) .run(); @@ -205,9 +213,9 @@ fn signal_handler_fires_through_sim_neo_parallel() { .build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .run(); diff --git a/exp/pecos-neo/tests/hugr_integration_test.rs b/exp/pecos-neo/tests/hugr_integration_test.rs index 82a65beb8..e6971ed05 100644 --- a/exp/pecos-neo/tests/hugr_integration_test.rs +++ b/exp/pecos-neo/tests/hugr_integration_test.rs @@ -15,7 +15,7 @@ #![cfg(feature = "hugr")] use pecos_core::QubitId; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_programs::Hugr; use std::path::PathBuf; @@ -37,7 +37,7 @@ fn test_hugr_bell_state_auto() { let results = sim_neo(load_hugr("bell_state.hugr")) .auto() .seed(42) - .shots(100) + .sampling(monte_carlo(100)) .build() .run(); @@ -55,7 +55,7 @@ fn test_hugr_single_hadamard_auto() { let results = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(42) - .shots(200) + .sampling(monte_carlo(200)) .build() .run(); @@ -77,9 +77,10 @@ fn test_hugr_explicit_engine() { let hugr = load_hugr("bell_state.hugr"); let source = String::from_utf8_lossy(&hugr.hugr).into_owned(); let results = sim_neo(source) + .auto() .classical(pecos_hugr::hugr_engine()) .seed(42) - .shots(10) + .sampling(monte_carlo(10)) .build() .run(); @@ -89,7 +90,12 @@ fn test_hugr_explicit_engine() { #[test] fn test_hugr_via_program_enum_auto() { let program = pecos_programs::Program::Hugr(load_hugr("bell_state.hugr")); - let results = sim_neo(program).auto().seed(42).shots(10).build().run(); + let results = sim_neo(program) + .auto() + .seed(42) + .sampling(monte_carlo(10)) + .build() + .run(); assert_eq!(results.len(), 10); } @@ -98,14 +104,14 @@ fn test_hugr_seeded_reproducibility() { let results1 = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(123) - .shots(50) + .sampling(monte_carlo(50)) .build() .run(); let results2 = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(123) - .shots(50) + .sampling(monte_carlo(50)) .build() .run(); @@ -124,14 +130,14 @@ fn test_hugr_different_seeds_differ() { let results1 = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(42) - .shots(50) + .sampling(monte_carlo(50)) .build() .run(); let results2 = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(99) - .shots(50) + .sampling(monte_carlo(50)) .build() .run(); diff --git a/exp/pecos-neo/tests/noise_comparison_test.rs b/exp/pecos-neo/tests/noise_comparison_test.rs index ae282640e..8e4d3c7d7 100644 --- a/exp/pecos-neo/tests/noise_comparison_test.rs +++ b/exp/pecos-neo/tests/noise_comparison_test.rs @@ -26,8 +26,12 @@ use pecos_neo::prelude::*; use pecos_simulators::SparseStab; use std::collections::BTreeMap; -const NUM_SHOTS: usize = 5000; -const TOLERANCE_PERCENT: f64 = 5.0; // Allow 5% difference in error rates +const NUM_SHOTS: usize = 20_000; +/// Comparison band in standard deviations. Flat percentage tolerances +/// previously masked a real physics difference (`GeneralNoiseModel`'s default +/// emission ratio shifting flip rates from 0.20 to 0.16 — inside a 5pp +/// band); sigma-based bands scale with the statistics instead. +const K_SIGMA: f64 = 5.0; /// Run a circuit with `GeneralNoiseModel` and count results. fn run_general_noise_model( @@ -119,9 +123,27 @@ fn outcome_percentage(counts: &BTreeMap, outcome: &str, total: us (count as f64 / total as f64) * 100.0 } -/// Compare two error rates and check if they're within tolerance. -fn rates_match(rate1: f64, rate2: f64, tolerance: f64) -> bool { - (rate1 - rate2).abs() <= tolerance +/// `K_SIGMA` band in percentage points for comparing two empirical rates +/// from independent `NUM_SHOTS`-shot binomial samples. A 0.1pp floor guards +/// degenerate zero-variance cases (rates at exactly 0% or 100%). +fn sigma_band_pct(rate1_pct: f64, rate2_pct: f64) -> f64 { + let p1 = rate1_pct / 100.0; + let p2 = rate2_pct / 100.0; + let var = (p1 * (1.0 - p1) + p2 * (1.0 - p2)) / NUM_SHOTS as f64; + (K_SIGMA * var.sqrt() * 100.0).max(0.1) +} + +/// Compare two empirical error rates within `K_SIGMA` of binomial noise. +fn rates_match(rate1: f64, rate2: f64) -> bool { + (rate1 - rate2).abs() <= sigma_band_pct(rate1, rate2) +} + +/// Compare an empirical rate against an analytic expectation within +/// `K_SIGMA` of binomial noise. +fn rate_matches_expected(rate_pct: f64, expected_pct: f64) -> bool { + let p = expected_pct / 100.0; + let band = (K_SIGMA * (p * (1.0 - p) / NUM_SHOTS as f64).sqrt() * 100.0).max(0.1); + (rate_pct - expected_pct).abs() <= band } #[test] @@ -185,8 +207,8 @@ fn test_single_qubit_depolarizing_comparison() { // So ~2/3 of errors result in |0⟩, ~1/3 result in |1⟩ assert!( - rates_match(general_zero, composable_zero, TOLERANCE_PERCENT), - "Error rates should match within {TOLERANCE_PERCENT}%: general={general_zero:.1}%, composable={composable_zero:.1}%" + rates_match(general_zero, composable_zero), + "Error rates should match within {K_SIGMA} sigma: general={general_zero:.1}%, composable={composable_zero:.1}%" ); } @@ -258,8 +280,8 @@ fn test_two_qubit_depolarizing_comparison() { println!(" ComposableNoiseModel: {composable_error:.1}% errors"); assert!( - rates_match(general_error, composable_error, TOLERANCE_PERCENT), - "Error rates should match within {TOLERANCE_PERCENT}%: general={general_error:.1}%, composable={composable_error:.1}%" + rates_match(general_error, composable_error), + "Error rates should match within {K_SIGMA} sigma: general={general_error:.1}%, composable={composable_error:.1}%" ); } @@ -311,18 +333,18 @@ fn test_measurement_error_comparison() { // Both should be close to 10% assert!( - rates_match(general_one, composable_one, TOLERANCE_PERCENT), - "Measurement error rates should match within {TOLERANCE_PERCENT}%: general={general_one:.1}%, composable={composable_one:.1}%" + rates_match(general_one, composable_one), + "Measurement error rates should match within {K_SIGMA} sigma: general={general_one:.1}%, composable={composable_one:.1}%" ); // Also verify they're close to expected value assert!( - (general_one - p_meas_0 * 100.0).abs() < TOLERANCE_PERCENT, + rate_matches_expected(general_one, p_meas_0 * 100.0), "GeneralNoiseModel measurement error rate should be close to {}: got {general_one:.1}%", p_meas_0 * 100.0 ); assert!( - (composable_one - p_meas_0 * 100.0).abs() < TOLERANCE_PERCENT, + rate_matches_expected(composable_one, p_meas_0 * 100.0), "ComposableNoiseModel measurement error rate should be close to {}: got {composable_one:.1}%", p_meas_0 * 100.0 ); @@ -377,8 +399,8 @@ fn test_preparation_error_comparison() { println!(" ComposableNoiseModel: {composable_one:.1}% |1⟩ (errors)"); assert!( - rates_match(general_one, composable_one, TOLERANCE_PERCENT), - "Preparation error rates should match within {TOLERANCE_PERCENT}%: general={general_one:.1}%, composable={composable_one:.1}%" + rates_match(general_one, composable_one), + "Preparation error rates should match within {K_SIGMA} sigma: general={general_one:.1}%, composable={composable_one:.1}%" ); } @@ -461,13 +483,8 @@ fn test_combined_noise_comparison() { // The correlated outcome rate should be similar assert!( - rates_match( - general_correlated, - composable_correlated, - TOLERANCE_PERCENT * 2.0 - ), - "Correlated rates should match within {}%: general={general_correlated:.1}%, composable={composable_correlated:.1}%", - TOLERANCE_PERCENT * 2.0 + rates_match(general_correlated, composable_correlated), + "Correlated rates should match within {K_SIGMA} sigma: general={general_correlated:.1}%, composable={composable_correlated:.1}%" ); } @@ -539,7 +556,7 @@ fn test_general_noise_model_builder_comparison() { println!(" pecos-neo GeneralNoiseModelBuilder: {composable_11:.1}% |11⟩"); assert!( - rates_match(general_11, composable_11, TOLERANCE_PERCENT * 2.0), + rates_match(general_11, composable_11), "Builder should produce equivalent results: general={general_11:.1}%, builder={composable_11:.1}%" ); } @@ -602,11 +619,11 @@ fn test_idle_noise_with_time_scale() { println!(" T1=10us, T2=5us, idle=1us"); println!(" Error rate: {error_rate:.1}% (expected ~10% from linear/T1 dephasing)"); - // With T1=10us and idle=1us, linear rate gives ~10% error probability - // (quadratic rate is negligible at this scale) - // Allow for statistical variation + // Analytic expectation: linear_rate = 1/T1 = 1e-4/ns, so 1000 ns idle + // gives p = 0.1 exactly (Z-only weights, detected via H basis). The + // quadratic T2 term contributes sin^2(4e-5) ~ 1.6e-9, negligible. assert!( - error_rate > 5.0 && error_rate < 20.0, - "Error rate {error_rate:.1}% should be in reasonable range for T1/T2 dephasing" + rate_matches_expected(error_rate, 10.0), + "Error rate {error_rate:.1}% should be within {K_SIGMA} sigma of the analytic 10% T1 dephasing rate" ); } diff --git a/exp/pecos-neo/tests/sim_neo_comparison_test.rs b/exp/pecos-neo/tests/sim_neo_comparison_test.rs index 2383a28ad..08d17996d 100644 --- a/exp/pecos-neo/tests/sim_neo_comparison_test.rs +++ b/exp/pecos-neo/tests/sim_neo_comparison_test.rs @@ -22,7 +22,7 @@ use pecos_engines::GeneralNoiseModelBuilder as EnginesNoiseBuilder; use pecos_engines::sim; use pecos_neo::noise::GeneralNoiseModelBuilder; use pecos_neo::prelude::*; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_qasm::qasm_engine; use std::collections::BTreeMap; @@ -134,10 +134,15 @@ fn test_sim_neo_vs_sim_deterministic_x() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() (Tool architecture) + // Run with sim_neo().auto() (Tool architecture) let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0)]); // Both should produce all 1s @@ -175,10 +180,15 @@ fn test_sim_neo_vs_sim_hadamard() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0)]); // Both should be roughly 50/50 @@ -225,7 +235,7 @@ fn test_sim_neo_vs_sim_bell_state() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -235,7 +245,12 @@ fn test_sim_neo_vs_sim_bell_state() { .mz(&[1]) .build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0), QubitId(1)]); // Both should only have 00 and 11 @@ -287,14 +302,15 @@ fn test_sim_neo_vs_sim_depolarizing_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let neo_noise = GeneralNoiseModelBuilder::new().with_p1(p1).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -351,7 +367,7 @@ fn test_sim_neo_vs_sim_measurement_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); let neo_noise = GeneralNoiseModelBuilder::new() @@ -359,8 +375,9 @@ fn test_sim_neo_vs_sim_measurement_noise() { .build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -665,8 +682,9 @@ fn test_sim_neo_ergonomic_builder_direct() { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let neo_results = sim_neo(circuit) + .auto() .noise(GeneralNoiseModelBuilder::new().with_p1(p1)) // No .build()! - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -691,14 +709,16 @@ fn test_sim_neo_convenience_methods() { // Using .depolarizing() convenience method let results_convenience = sim_neo(circuit.clone()) + .auto() .depolarizing(0.05) - .shots(500) + .sampling(monte_carlo(500)) .seed(42) .build() .run(); // Using explicit GeneralNoiseModelBuilder - must match what .depolarizing() does let results_explicit = sim_neo(circuit) + .auto() .noise( GeneralNoiseModelBuilder::new() .with_p1(0.05) @@ -706,7 +726,7 @@ fn test_sim_neo_convenience_methods() { .with_p_prep(0.05) .with_p_meas_symmetric(0.05), ) - .shots(500) + .sampling(monte_carlo(500)) .seed(42) .build() .run(); @@ -737,7 +757,11 @@ fn test_sim_neo_reusable() { // Test that sim_neo Simulation handle can be rerun with different configs let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - let mut sim = sim_neo(circuit).shots(100).seed(42).build(); + let mut sim = sim_neo(circuit) + .auto() + .sampling(monte_carlo(100)) + .seed(42) + .build(); // First run let results1 = sim.run(); @@ -779,9 +803,19 @@ fn test_sim_neo_determinism() { // Same seed should produce identical results let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - let results1 = sim_neo(circuit.clone()).shots(100).seed(42).build().run(); + let results1 = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(100)) + .seed(42) + .build() + .run(); - let results2 = sim_neo(circuit).shots(100).seed(42).build().run(); + let results2 = sim_neo(circuit) + .auto() + .sampling(monte_carlo(100)) + .seed(42) + .build() + .run(); // Results should be identical for (o1, o2) in results1.outcomes.iter().zip(results2.outcomes.iter()) { @@ -816,7 +850,7 @@ fn test_sim_neo_vs_sim_noiseless_exact() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() - no noise + // Run with sim_neo().auto() - no noise let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -826,7 +860,12 @@ fn test_sim_neo_vs_sim_noiseless_exact() { .mz(&[1]) .build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0), QubitId(1)]); // Both should produce 100% |11> @@ -875,8 +914,9 @@ fn test_sim_neo_noise_level_scaling() { let neo_noise = GeneralNoiseModelBuilder::new().with_p1(p1).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -943,7 +983,7 @@ fn test_sim_neo_vs_sim_zero_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() with zero-noise model + // Run with sim_neo().auto() with zero-noise model let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -961,8 +1001,9 @@ fn test_sim_neo_vs_sim_zero_noise() { .build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1010,8 +1051,9 @@ fn test_sim_neo_high_noise_chaos() { let neo_noise = GeneralNoiseModelBuilder::new().with_p1(p1).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1071,7 +1113,7 @@ fn test_sim_neo_vs_sim_two_qubit_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -1084,8 +1126,9 @@ fn test_sim_neo_vs_sim_two_qubit_noise() { let neo_noise = GeneralNoiseModelBuilder::new().with_p2(p2).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1141,14 +1184,15 @@ fn test_sim_neo_vs_sim_preparation_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); let neo_noise = GeneralNoiseModelBuilder::new().with_p_prep(p_prep).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1211,7 +1255,7 @@ fn test_sim_neo_vs_sim_combined_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -1229,8 +1273,9 @@ fn test_sim_neo_vs_sim_combined_noise() { .build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1291,7 +1336,7 @@ fn test_sim_neo_vs_sim_ghz_state() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 3); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -1304,7 +1349,12 @@ fn test_sim_neo_vs_sim_ghz_state() { .mz(&[2]) .build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0), QubitId(1), QubitId(2)]); // Both should only have 000 and 111 diff --git a/exp/zlup/.gitignore b/exp/zlup/.gitignore new file mode 100644 index 000000000..537d901d3 --- /dev/null +++ b/exp/zlup/.gitignore @@ -0,0 +1,22 @@ +# Build artifacts +/target/ + +# Fuzzing - corpus and artifacts are machine-generated +/fuzz/target/ +/fuzz/corpus/ +/fuzz/artifacts/ + +# Proptest regressions (auto-generated failure cases) +*.proptest-regressions + +# IDE +.idea/ +*.iml + +# Python / uv +.venv/ +__pycache__/ +*.pyc + +# MkDocs +/site/ diff --git a/exp/zlup/Cargo.toml b/exp/zlup/Cargo.toml new file mode 100644 index 000000000..bda2f7ac4 --- /dev/null +++ b/exp/zlup/Cargo.toml @@ -0,0 +1,93 @@ +[package] +name = "zlup" +version = "0.1.0" +edition = "2024" +description = "Zlup: Experimental quantum language with Zig semantics and Rust/Python syntax for reliable QEC" +license = "Apache-2.0" +repository = "https://github.com/PECOS-packages/PECOS" +readme = "README.md" +keywords = ["quantum", "programming-language", "experimental"] +categories = ["science", "compilers"] + +# This is an experimental crate - not for production use +[badges] +maintenance = { status = "experimental" } + +[lib] +name = "zlup" + +# CLI binary +[[bin]] +name = "zlup" +path = "src/main.rs" +required-features = ["cli"] + +# LSP server binary +[[bin]] +name = "zlups" +path = "src/lsp/main.rs" +required-features = ["lsp"] + +[dependencies] +# Parser generator +pest = "2.7" +pest_derive = "2.7" + +# Error handling +thiserror = "1.0" +miette = { version = "7.0", features = ["fancy"] } + +# Utilities +smol_str = "0.3" # Interned strings for identifiers + +# Logging +log = "0.4" +env_logger = "0.11" + +# Serialization (for SLR-AST JSON bridge) +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Config file parsing +toml = "0.8" + +# CLI (optional) +clap = { version = "4.5", features = ["derive", "env"], optional = true } + +# HUGR codegen (optional) +tket = { workspace = true, optional = true } + +# LSP server (optional) +tower-lsp = { version = "0.20", optional = true } +tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "io-std"], optional = true } +once_cell = { version = "1.19", optional = true } + +[dev-dependencies] +pretty_assertions = "1.4" +insta = "1.40" # Snapshot testing +tket.workspace = true # For testing HUGR codegen +tempfile = "3.10" # For module system tests +criterion = { version = "0.5", features = ["html_reports"] } # Benchmarking +proptest = "1.4" # Property-based testing + +[[bench]] +name = "compiler" +harness = false + +# CLI integration tests drive the `zlup` binary, which only exists with the +# `cli` feature. Gate the test target so it is skipped (not failed) when `cli` +# is off, e.g. the default-feature workspace test run. +[[test]] +name = "cli" +required-features = ["cli"] + +[features] +default = [] +# Enable CLI binary +cli = ["dep:clap"] +# Enable HUGR code generation +hugr = ["dep:tket"] +# Enable LSP server +lsp = ["dep:tower-lsp", "dep:tokio", "dep:once_cell"] +# All features for full build +full = ["cli", "hugr", "lsp"] diff --git a/exp/zlup/Justfile b/exp/zlup/Justfile new file mode 100644 index 000000000..536fdc89b --- /dev/null +++ b/exp/zlup/Justfile @@ -0,0 +1,235 @@ +# Zlup Development Justfile +# Cross-platform command runner (Windows, macOS, Linux) +# Install: cargo install just +# Usage: just or just --list + +# Default recipe: show help +default: + @just --list + +# ============================================================================= +# Settings +# ============================================================================= + +set shell := ["bash", "-cu"] +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] + +# ============================================================================= +# Documentation +# ============================================================================= + +# Build and serve documentation (opens in browser) +docs port="8000": + #!/usr/bin/env bash + set -euo pipefail + if lsof -i :{{port}} >/dev/null 2>&1; then + echo "Port {{port}} is already in use." + echo "" + echo "Options:" + echo " - Use a different port: just docs 8001" + echo " - Stop existing server: just docs-stop {{port}}" + echo " - Find process: lsof -i :{{port}}" + exit 1 + fi + echo "Starting documentation server at http://127.0.0.1:{{port}}" + uv run mkdocs serve --open -a 127.0.0.1:{{port}} + +# Build documentation to site/ +docs-build: + uv run mkdocs build --clean + +# Serve documentation without opening browser +docs-serve port="8000": + #!/usr/bin/env bash + set -euo pipefail + if lsof -i :{{port}} >/dev/null 2>&1; then + echo "Port {{port}} is already in use. Try: just docs-stop {{port}}" + exit 1 + fi + uv run mkdocs serve -a 127.0.0.1:{{port}} + +# Stop documentation server on a port +docs-stop port="8000": + #!/usr/bin/env bash + set -euo pipefail + pid=$(lsof -t -i :{{port}} 2>/dev/null || true) + if [ -n "$pid" ]; then + echo "Stopping process $pid on port {{port}}..." + kill $pid + echo "Done." + else + echo "No process found on port {{port}}." + fi + +# ============================================================================= +# Building +# ============================================================================= + +# Build Zlup compiler (debug) +build: + cargo build --features cli + +# Build Zlup compiler (release) +build-release: + cargo build --release --features cli + +# Build Zlup compiler (release with native CPU optimizations) +build-native: + RUSTFLAGS="-C target-cpu=native" cargo build --release --features cli + +# ============================================================================= +# Testing +# ============================================================================= + +# Run all tests +test: + cargo test --features cli + +# Run all tests with output +test-verbose: + cargo test --features cli -- --nocapture + +# Run specific test by name +test-one name: + cargo test --features cli {{name}} -- --nocapture + +# Run property-based tests only +test-proptest: + cargo test --features cli proptest + +# Run CLI integration tests only +test-cli: + cargo test --features cli cli_tests + +# ============================================================================= +# Linting / Formatting +# ============================================================================= + +# Check code with clippy +clippy: + cargo clippy --features cli --all-targets -- -D warnings + +# Check Rust formatting +fmt: + cargo fmt --check + +# Fix Rust formatting +fmt-fix: + cargo fmt + +# Run all linting checks +lint: fmt clippy + +# Fix all auto-fixable issues +lint-fix: fmt-fix + cargo clippy --features cli --fix --allow-dirty + +# ============================================================================= +# Running +# ============================================================================= + +# Run Zlup compiler on a file +run file: + cargo run --features cli -- {{file}} + +# Run Zlup compiler on a file (release build) +run-release file: + cargo run --release --features cli -- {{file}} + +# Evaluate an expression +eval expr: + cargo run --features cli -- eval "{{expr}}" + +# Check a file without running +check file: + cargo run --features cli -- check {{file}} + +# Compile to SLR-AST JSON +compile-slr file: + cargo run --features cli -- compile --target slr {{file}} + +# Compile to OpenQASM +compile-qasm file: + cargo run --features cli -- compile --target qasm {{file}} + +# ============================================================================= +# Development +# ============================================================================= + +# Watch for changes and run tests +watch: + cargo watch -x "test --features cli" + +# Watch for changes and check +watch-check: + cargo watch -x "check --features cli" + +# Generate and open rustdoc +rustdoc: + cargo doc --features cli --open + +# ============================================================================= +# Examples +# ============================================================================= + +# Run all example programs +examples: + #!/usr/bin/env bash + set -euo pipefail + echo "Running Zlup examples..." + for f in examples/*.zlp; do + echo "==> $f" + cargo run --features cli -- "$f" || true + echo "" + done + +# Compile all examples to SLR-AST +examples-slr: + #!/usr/bin/env bash + set -euo pipefail + echo "Compiling examples to SLR-AST..." + for f in examples/*.zlp; do + echo "==> $f" + cargo run --features cli -- compile --target slr "$f" || true + echo "" + done + +# ============================================================================= +# Documentation Checks +# ============================================================================= + +# Check Zlup code snippets in documentation for syntax/semantic errors +check-docs: build + python3 scripts/check_docs.py + +# Check docs with verbose output (shows each snippet result) +check-docs-verbose: build + python3 scripts/check_docs.py --verbose + +# ============================================================================= +# Cleaning +# ============================================================================= + +# Clean build artifacts +clean: + cargo clean + rm -rf site/ + +# Clean everything including Python venv +clean-all: clean + rm -rf .venv/ + rm -f uv.lock + +# ============================================================================= +# Setup +# ============================================================================= + +# Install Python dependencies for docs +setup-docs: + uv sync + +# Install all development dependencies +setup: setup-docs + @echo "Development environment ready!" + @echo " - Run 'just test' to run tests" + @echo " - Run 'just docs' to view documentation" diff --git a/exp/zlup/README.md b/exp/zlup/README.md new file mode 100644 index 000000000..ab6f72ff2 --- /dev/null +++ b/exp/zlup/README.md @@ -0,0 +1,96 @@ +# Zlup + +> **EXPERIMENTAL / EXPLORATORY** - Zlup is a research experiment, not a production language. + +A quantum programming language for QEC research: simple, low-level, and predictable by design. + +## Overview + +Zlup is the **low-level complement to Guppy** in the PECOS ecosystem. While Guppy provides a high-level, Pythonic experience with linear types for safety, Zlup explores a different point in the design space: + +| | Guppy | Zlup | +|---|---|---| +| **Philosophy** | High-level, Pythonic | Low-level, explicit | +| **Safety mechanism** | Linear type system | Constraints make unsafe impossible | +| **Target users** | QEC researchers | Systems programmers | + +**Design principles:** Simple. Explicit. No magic. + +- Zig semantics with Rust/Python syntax +- NASA Power of 10 compliance for reliability +- Safe by constraint (no recursion, no escaping references) + +## Quick Example + +```zig +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Create GHZ state + h q[0]; + cx (q[0], q[1]); + cx (q[1], q[2]); + cx (q[2], q[3]); + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + return; +} +``` + +## Building + +```bash +# Build the compiler +cargo build --features cli + +# Run tests +cargo test --features cli + +# Compile a program +cargo run --features cli -- compile program.zlp + +# Check without compiling +cargo run --features cli -- check program.zlp +``` + +Or use the Justfile: + +```bash +just build # Build compiler +just test # Run tests +just docs # View documentation +``` + +## Documentation + +Full documentation is available in the `docs/` directory: + +| Document | Description | +|----------|-------------| +| [Tutorial](docs/tutorial.md) | Getting started guide | +| [Language Syntax](docs/syntax.md) | Complete language reference | +| [CLI Reference](docs/cli.md) | Command-line interface | +| [Standard Library](docs/stdlib.md) | Standard library reference | +| [Error Messages](docs/errors.md) | Error messages guide | +| [Design Philosophy](docs/design.md) | Why Zlup exists | +| [Rust Integration](docs/rust-integration.md) | FFI and native backends | +| [IDE Setup](docs/ide-setup.md) | Editor configuration | + +To view docs locally: + +```bash +just docs # Starts server at http://127.0.0.1:8000 +``` + +## Status + +Zlup is **highly experimental**—an exploration of language design, not a production tool. The goal is to learn from building it, not to replace existing tools. + +See the [Design Philosophy](docs/design.md) for more on why Zlup exists. + +## License + +Apache-2.0 diff --git a/exp/zlup/benches/compiler.rs b/exp/zlup/benches/compiler.rs new file mode 100644 index 000000000..91a3f6011 --- /dev/null +++ b/exp/zlup/benches/compiler.rs @@ -0,0 +1,314 @@ +//! Performance benchmarks for the Zlup compiler. +//! +//! Run with: cargo bench --bench compiler + +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use zlup::codegen::{QasmCodegen, SlrCodegen}; +use zlup::semantic::SemanticAnalyzer; + +// ============================================================================= +// Test Programs +// ============================================================================= + +/// Minimal Bell state program (2 qubits, 3 operations) +const SMALL_PROGRAM: &str = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + return unit; +} +"#; + +/// Medium-sized GHZ state program (4 qubits, 6 operations) +const MEDIUM_PROGRAM: &str = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + h q[0]; + cx (q[0], q[1]); + cx (q[0], q[2]); + cx (q[0], q[3]); + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + return unit; +} +"#; + +/// Generate a program with N qubits creating a GHZ state +fn generate_ghz_program(n: usize) -> String { + let mut s = String::new(); + s.push_str("pub fn main() -> unit {\n"); + s.push_str(&format!(" q := qalloc({n});\n")); + s.push_str(" pz q;\n"); + s.push_str(" h q[0];\n"); + + // Create entanglement chain + for i in 1..n { + s.push_str(&format!(" cx (q[0], q[{i}]);\n")); + } + + // Measurement + let indices: Vec = (0..n).map(|i| format!("q[{i}]")).collect(); + s.push_str(&format!( + " results: [{n}]u1 = mz([{n}]u1) [{}];\n", + indices.join(", ") + )); + s.push_str(" return unit;\n"); + s.push_str("}\n"); + s +} + +/// Generate a program with multiple functions +fn generate_multi_function_program(n_funcs: usize) -> String { + let mut s = String::new(); + + // Helper functions + for i in 0..n_funcs { + s.push_str(&format!( + r#" +fn helper_{i}(x: i32) -> i32 {{ + y := x + {i}; + return y; +}} +"# + )); + } + + // Main function that calls helpers + s.push_str("\npub fn main() -> unit {\n"); + s.push_str(" q := qalloc(2);\n"); + s.push_str(" pz q;\n"); + s.push_str(" h q[0];\n"); + s.push_str(" cx (q[0], q[1]);\n"); + + // Call each helper + for i in 0..n_funcs { + s.push_str(&format!(" val_{i} := helper_{i}({i});\n")); + } + + s.push_str(" return unit;\n"); + s.push_str("}\n"); + s +} + +// ============================================================================= +// Parsing Benchmarks +// ============================================================================= + +fn bench_parsing(c: &mut Criterion) { + let mut group = c.benchmark_group("parsing"); + + // Small program + group.throughput(Throughput::Bytes(SMALL_PROGRAM.len() as u64)); + group.bench_function("small_2q", |b| { + b.iter(|| zlup::parse(black_box(SMALL_PROGRAM)).unwrap()) + }); + + // Medium program + group.throughput(Throughput::Bytes(MEDIUM_PROGRAM.len() as u64)); + group.bench_function("medium_4q", |b| { + b.iter(|| zlup::parse(black_box(MEDIUM_PROGRAM)).unwrap()) + }); + + // Scaling with qubit count + for n in [8, 16, 32, 64] { + let program = generate_ghz_program(n); + group.throughput(Throughput::Bytes(program.len() as u64)); + group.bench_with_input(BenchmarkId::new("ghz", n), &program, |b, prog| { + b.iter(|| zlup::parse(black_box(prog)).unwrap()) + }); + } + + // Scaling with function count + for n in [5, 10, 20] { + let program = generate_multi_function_program(n); + group.throughput(Throughput::Bytes(program.len() as u64)); + group.bench_with_input(BenchmarkId::new("multi_func", n), &program, |b, prog| { + b.iter(|| zlup::parse(black_box(prog)).unwrap()) + }); + } + + group.finish(); +} + +// ============================================================================= +// Semantic Analysis Benchmarks +// ============================================================================= + +fn bench_semantic(c: &mut Criterion) { + let mut group = c.benchmark_group("semantic"); + + // Small program + let small_ast = zlup::parse(SMALL_PROGRAM).unwrap(); + group.bench_function("small_2q", |b| { + b.iter(|| { + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(black_box(&small_ast)).unwrap() + }) + }); + + // Medium program + let medium_ast = zlup::parse(MEDIUM_PROGRAM).unwrap(); + group.bench_function("medium_4q", |b| { + b.iter(|| { + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(black_box(&medium_ast)).unwrap() + }) + }); + + // Scaling with qubit count + for n in [8, 16, 32, 64] { + let program = generate_ghz_program(n); + let ast = zlup::parse(&program).unwrap(); + group.bench_with_input(BenchmarkId::new("ghz", n), &ast, |b, ast| { + b.iter(|| { + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(black_box(ast)).unwrap() + }) + }); + } + + // Scaling with function count + for n in [5, 10, 20] { + let program = generate_multi_function_program(n); + let ast = zlup::parse(&program).unwrap(); + group.bench_with_input(BenchmarkId::new("multi_func", n), &ast, |b, ast| { + b.iter(|| { + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(black_box(ast)).unwrap() + }) + }); + } + + group.finish(); +} + +// ============================================================================= +// SLR Code Generation Benchmarks +// ============================================================================= + +fn bench_slr_codegen(c: &mut Criterion) { + let mut group = c.benchmark_group("codegen_slr"); + + // Small program + let small_ast = zlup::parse(SMALL_PROGRAM).unwrap(); + group.bench_function("small_2q", |b| { + b.iter(|| { + let mut codegen = SlrCodegen::new(); + codegen.compile(black_box(&small_ast)).unwrap() + }) + }); + + // Medium program + let medium_ast = zlup::parse(MEDIUM_PROGRAM).unwrap(); + group.bench_function("medium_4q", |b| { + b.iter(|| { + let mut codegen = SlrCodegen::new(); + codegen.compile(black_box(&medium_ast)).unwrap() + }) + }); + + // Scaling with qubit count + for n in [8, 16, 32, 64] { + let program = generate_ghz_program(n); + let ast = zlup::parse(&program).unwrap(); + group.bench_with_input(BenchmarkId::new("ghz", n), &ast, |b, ast| { + b.iter(|| { + let mut codegen = SlrCodegen::new(); + codegen.compile(black_box(ast)).unwrap() + }) + }); + } + + group.finish(); +} + +// ============================================================================= +// QASM Code Generation Benchmarks +// ============================================================================= + +fn bench_qasm_codegen(c: &mut Criterion) { + let mut group = c.benchmark_group("codegen_qasm"); + + // Small program + let small_ast = zlup::parse(SMALL_PROGRAM).unwrap(); + group.bench_function("small_2q", |b| { + b.iter(|| { + let mut codegen = QasmCodegen::new(); + codegen.compile(black_box(&small_ast)).unwrap() + }) + }); + + // Medium program + let medium_ast = zlup::parse(MEDIUM_PROGRAM).unwrap(); + group.bench_function("medium_4q", |b| { + b.iter(|| { + let mut codegen = QasmCodegen::new(); + codegen.compile(black_box(&medium_ast)).unwrap() + }) + }); + + // Scaling with qubit count + for n in [8, 16, 32, 64] { + let program = generate_ghz_program(n); + let ast = zlup::parse(&program).unwrap(); + group.bench_with_input(BenchmarkId::new("ghz", n), &ast, |b, ast| { + b.iter(|| { + let mut codegen = QasmCodegen::new(); + codegen.compile(black_box(ast)).unwrap() + }) + }); + } + + group.finish(); +} + +// ============================================================================= +// End-to-End Pipeline Benchmarks +// ============================================================================= + +fn bench_full_pipeline(c: &mut Criterion) { + let mut group = c.benchmark_group("full_pipeline"); + + // Parse -> Semantic -> SLR + for n in [4, 16, 64] { + let program = generate_ghz_program(n); + group.bench_with_input(BenchmarkId::new("to_slr", n), &program, |b, prog| { + b.iter(|| { + let ast = zlup::parse(black_box(prog)).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&ast).unwrap(); + let mut codegen = SlrCodegen::new(); + codegen.compile(&ast).unwrap() + }) + }); + } + + // Parse -> Semantic -> QASM + for n in [4, 16, 64] { + let program = generate_ghz_program(n); + group.bench_with_input(BenchmarkId::new("to_qasm", n), &program, |b, prog| { + b.iter(|| { + let ast = zlup::parse(black_box(prog)).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&ast).unwrap(); + let mut codegen = QasmCodegen::new(); + codegen.compile(&ast).unwrap() + }) + }); + } + + group.finish(); +} + +criterion_group!( + benches, + bench_parsing, + bench_semantic, + bench_slr_codegen, + bench_qasm_codegen, + bench_full_pipeline, +); +criterion_main!(benches); diff --git a/exp/zlup/docs/cli.md b/exp/zlup/docs/cli.md new file mode 100644 index 000000000..0326a34fd --- /dev/null +++ b/exp/zlup/docs/cli.md @@ -0,0 +1,244 @@ +# CLI Reference + +Zlup provides a command-line interface for compiling, checking, and running Zlup programs. + +## Project Commands + +### Initialize a Project + +```bash +# Create a new project +zlup init my-project + +# Create project in current directory +zlup init my-project --here +``` + +### Build a Project + +```bash +# Build using zlup.toml configuration +zlup build + +# Build with strict mode override +zlup build --strict true + +# Build to different target +zlup build --target hugr +``` + +## Direct Compilation + +### Compile + +```bash +# Compile to SLR-AST JSON (default) +zlup compile program.zlp + +# Compile to specific target +zlup compile --target slr program.zlp +zlup compile --target qasm program.zlp +zlup compile --target hugr program.zlp + +# Read from stdin +echo 'fn main() -> unit { return; }' | zlup compile - +``` + +### Check + +```bash +# Check syntax and semantics +zlup check program.zlp + +# Strict mode (NASA Power of 10 checks) +zlup check --strict program.zlp +``` + +## Formatting + +Format Zlup source files to a consistent style. + +```bash +# Format and print to stdout +zlup fmt program.zlp + +# Format in place +zlup fmt --write program.zlp +zlup fmt -w program.zlp + +# Check formatting (exit 1 if not formatted) +zlup fmt --check program.zlp + +# Read from stdin +cat program.zlp | zlup fmt - +``` + +## Linting + +Zlup includes a linter with automatic fix capabilities, similar to ruff for Python. + +### Basic Usage + +```bash +# Run linter (strict mode by default) +zlup lint program.zlp + +# Run with relaxed or minimal rules +zlup lint --level relaxed program.zlp +zlup lint --level minimal program.zlp +``` + +### Auto-Fix + +```bash +# Preview fixes without applying (shows diff) +zlup lint --diff program.zlp + +# Apply safe fixes automatically +zlup lint --fix program.zlp + +# Apply both safe and unsafe fixes +zlup lint --fix --unsafe-fixes program.zlp +``` + +### Output Options + +```bash +# Show statistics only +zlup lint --statistics program.zlp + +# Treat warnings as errors +zlup lint --deny-warnings program.zlp + +# Output formats +zlup lint --format pretty program.zlp # Human-readable (default) +zlup lint --format json program.zlp # JSON for tooling +zlup lint --format compact program.zlp # One line per diagnostic +``` + +### Fix Safety Levels + +| Safety | Description | Examples | +|--------|-------------|----------| +| Safe | Preserves semantics, guaranteed correct | Prefix unused vars with `_`, decimal to fraction | +| Unsafe | Probably safe, may need manual verification | Complex refactorings | + +### Common Lint Rules + +| Rule | Description | +|------|-------------| +| `unused_variable` | Detects unused variables (fixable: prefix with `_`) | +| `unused_function` | Detects unused non-public functions | +| `function_naming` | Enforces snake_case for function names | +| `variable_naming` | Enforces snake_case for variable names | +| `type_naming` | Enforces PascalCase for type names | +| `deep_nesting` | Warns on excessive nesting depth (NASA PoT Rule 1) | +| `low_assertion_density` | Warns when code lacks assertions (NASA PoT Rule 5) | +| `prefer_fraction_turns` | Suggests exact fractions over decimals (fixable) | +| `prefer_turns_over_radians` | Suggests turns over radians for precision | + +## Parallelism Analysis + +Analyze Zlup programs for parallelism opportunities: + +```bash +# Analyze a program +zlup analyze program.zlp + +# JSON output for tooling +zlup analyze program.zlp --format json + +# Verbose output with dependency graph +zlup analyze program.zlp --verbose + +# Read from stdin +cat program.zlp | zlup analyze - +``` + +The analyzer identifies: +- Qubit allocator lifetimes and scopes +- Operation dependencies (qubit and data dependencies) +- Parallel execution layers (operations that can run simultaneously) + +## Expression Evaluation + +Quick evaluation of expressions for experimentation: + +```bash +# Evaluate arithmetic +zlup eval "2 + 3 * 4" # 14 + +# Evaluate comparisons +zlup eval "10 > 5" # true + +# Strings +zlup eval '"hello"' # "hello" + +# Read from stdin +echo "100 / 4" | zlup eval - + +# Verbose mode (show AST) +zlup eval --verbose "1 + 2" +``` + +## Project Configuration + +Zlup projects use a `zlup.toml` file for configuration: + +```toml +[package] +name = "my-quantum-program" +version = "0.1.0" +entry = "main.zlp" +description = "A quantum application" +authors = ["Alice"] + +[build] +strict = false # Enable NASA Power of 10 strict checks +target = "slr" # Default: "slr" or "hugr" +output_dir = "build" # Output directory +``` + +## Compilation Targets + +Zlup can compile to multiple targets: + +``` +Zlup Source (.zlp) + | + v + Zlup AST + | + +---+---+ + | | + v v +SLR-AST HUGR + | | + v v + Guppy Native + QASM Execution +``` + +| Target | Description | +|--------|-------------| +| `slr` | SLR-AST JSON for Python/PECOS bridge (default) | +| `qasm` | OpenQASM 2.0 output | +| `hugr` | HUGR IR for native execution | + +## Debugging + +### Parse AST + +Dump the parsed AST for debugging and development: + +```bash +# Debug format (Rust Debug output) +zlup parse program.zlp + +# JSON format +zlup parse --format json program.zlp +zlup parse -f json program.zlp + +# Read from stdin +echo 'fn main() -> unit { return; }' | zlup parse - +``` diff --git a/exp/zlup/docs/design.md b/exp/zlup/docs/design.md new file mode 100644 index 000000000..555b9c01a --- /dev/null +++ b/exp/zlup/docs/design.md @@ -0,0 +1,1684 @@ +# Zlup: Zig Semantics with Rust/Python Syntax + +## Status + +> **EXPERIMENTAL / EXPLORATORY** - Zlup is a research experiment exploring whether a quantum language with these design goals is feasible. It is not a production language—do not use it for real projects. The value is in what we learn from building it. + +--- + +## Overview + +Zlup is the **low-level reflection of Guppy** in the PECOS ecosystem. Where Guppy provides a high-level, Pythonic experience with linear types for safety, Zlup explores the opposite end of the design spectrum: **simple, explicit, low-level control**. + +### The Guppy-Zlup Duality + +| Concern | Guppy | Zlup | +|---------|-------|------| +| **Abstraction** | High-level, hide complexity | Low-level, expose control | +| **Safety model** | Linear type system | Simplicity + constraints | +| **Resource management** | Implicit (type system tracks) | Explicit (allocators) | +| **Target audience** | QEC researchers, algorithm developers | Low-level QEC developers, systems programmers | +| **Integration** | Python-embedded | Rust-native | +| **Philosophy** | "Make it easy" | "Make it clear" | + +### When to Use Zlup + +Zlup is designed for **quantum orchestration and Rust integration**: + +- **Quantum control flow**: Gate sequences, syndrome extraction, correction application +- **FFI to Rust backends**: Calling decoders, simulators, and classical algorithms +- **Simulation infrastructure**: Noise modeling, error injection, state tracking +- **Compilation target**: Guppy programs can target Zlup for Rust execution + +**Complex classical algorithms (MWPM, ML decoders, etc.) belong in native code**—Rust is preferred for safety, but C/C++/Zig are supported via FFI (C ABI). Zlup orchestrates the quantum side and calls native backends for heavy classical computation. + +To make Rust integration ergonomic, Zlup provides the **`zlup-ffi` crate**—a Rust library with: +- `Decoder`, `NoiseModel`, `Simulator` traits that decoders and backends implement +- `#[zlup_export]` proc macro that generates C ABI wrappers automatically +- FFI-safe types (`PackedBits`, `QubitId`, `GateType`) for type-safe interop +- `zlup bindgen` command to generate Zlup declarations from Rust code + +See [docs/rust-integration.md](rust-integration.md) for the full guide. + +Most QEC researchers who prefer Python workflows should use **Guppy**. Zlup is for the systems layer that connects quantum operations to classical backends. + +Design influences include: + +- **Rust's safety goals**: Inspired by Rust's commitment to safety, but pursuing it through + simplicity and constraints rather than a complex type system +- **More constrained than Zig**: Zig demonstrates that expressivity doesn't require complexity— + Zlup pushes further, adding constraints so that complex safety mechanisms become unnecessary +- **Rust/Python syntax**: Familiar surface syntax for broader accessibility +- **NASA Power of 10**: Bounded loops, fixed resource limits, predictable execution + +### Safety Through Constraints, Not Complexity + +The core philosophy: **if the language is constrained enough, you don't need sophisticated type systems for safety**. + +| Approach | How It Achieves Safety | Complexity | +|----------|------------------------|------------| +| Rust | Borrow checker, lifetimes, ownership | High (but powerful) | +| Guppy | Linear types | Medium-high | +| Zig | Explicit, no hidden behavior | Medium | +| **Zlup** | Constraints + simplicity | Low | + +Zlup explores whether scoping rules, bounded resources, and explicit control flow can be designed so that: +- The "obvious" code is the safe code +- Unsafe patterns are structurally impossible, not just flagged by a type checker +- You don't need to understand complex type theory to write correct programs + +This is **explicit over implicit**—no magic, no hidden behavior, no surprises. The constraints aren't limitations on expressivity; they're guardrails that make unsafe patterns impossible to express in the first place. + +Zlup compiles to multiple targets: + +- **SLR-AST**: JSON bridge enabling integration with Guppy and Python/PECOS +- **HUGR**: For hardware backends (shared with Guppy) +- **PHIR**: For simulator targeting (future) + +--- + +## Design Principles + +- **Expressivity through simplicity**: Powerful programs without complexity or magic +- **Safety through constraints**: Restrictions that make unsafe patterns impossible +- **Reliability and predictability**: Bounded, analyzable programs for QEC + +--- + +## Relationship to Guppy + +Zlup complements Guppy by exploring a different point in the design space. Guppy's +linear type system provides strong safety guarantees with an elegant Python-embedded +experience. Zlup explores whether similar safety can be achieved through explicit +allocator-based resource management, which may be useful for: + +- Rust-native simulation workflows +- Cases where explicit low-level control is preferred +- Compiling Guppy programs to a Rust-friendly representation + +| Aspect | Guppy | Zlup | +|-------------------|--------------------------|----------------------| +| Type safety | Linear types | Allocator model | +| Integration | Python-embedded | Standalone / Rust | +| Abstraction | Higher-level | Lower-level | +| Resource tracking | Type system | Structural lifetimes | +| Primary use | User-facing programs | Compilation target | + +### Why Explore This Approach? + +Zlup is inspired by Rust's commitment to safety but pursues it through simplicity and +constraints. Where Rust achieves safety through a sophisticated type system, Zlup explores +whether similar guarantees can emerge from a simpler language with stricter constraints. + +Zig's design principles—particularly its commitment to no magic and its demonstration that +expressivity doesn't require complexity—align well with this goal. Zlup may push even +further toward simplicity given the NASA Power of 10 emphasis, while preserving the +expressivity needed for complex QEC algorithms. + +Zig's principles map well to quantum computing needs: + +| Zig Principle | Quantum Application | +|-----------------------|-----------------------------------------| +| No hidden allocations | Explicit qubit lifecycle | +| Comptime | Circuit generation, parameterized codes | +| Simple and explicit | Readable QEC protocols | +| Safety without GC | Resource safety via allocators | + +### NASA Power of 10 Alignment + +Zlup follows NASA's Power of 10 rules—designed for mission-critical systems where failure is not an option. **Production QEC infrastructure has the same requirements**: a subtle bug in a decoder, an unbounded loop during syndrome processing, or unexpected memory allocation could corrupt an entire quantum computation. + +These constraints aren't limitations—they're what mature, reliable QEC infrastructure demands: + +| Rule | Zlup Implementation | +|--------------------------------|-------------------------------------------| +| 1. Simple control flow | No goto, no while, no recursion | +| 2. Fixed loop bounds | Only bounded `for i in 0..n` loops | +| 3. No dynamic alloc after init | Base allocator in main, children derive | +| 4. Small functions | Encouraged by module system | +| 5. Assertions | Built-in `test` blocks, comptime checks | +| 6. Minimal scope | Block scoping, automatic release | +| 7. Check returns | Error unions, optional types | +| 8. Limited preprocessor | Comptime replaces macros | +| 9. Limited pointers | Allocator refs, not raw pointers | +| 10. All warnings | Strict mode enabled by default | + +**Safe by Constraint**: Zlup enforces safety unconditionally—recursion is always forbidden, references to local variables cannot escape functions, and dangling pointers are structurally impossible. These aren't optional "strict mode" checks; they're fundamental to the language's memory model. + +--- + +## Language Design + +### Core Concepts + +#### 1. Allocator-Based Qubit Management + +```zlup +pub fn main() -> unit { + // Simple case: gates don't require mut + q := qalloc(4); + pz q; + h q[0]; + cx (q[0], q[1]); + return; +} + +pub fn partitioned_example() -> unit { + // Partitioning: mut required on parent for .child() + mut base := qalloc(100); + + // Children track resources from parent (base is mutated) + // but children themselves don't need mut for gate application + data := base.child(9); + ancilla := base.child(8); + + pz data; + h data[0]; + cx (data[0], ancilla[0]); + + // Automatic release when scope ends + return; +} +``` + +#### 2. Two Slot States + +Qubits have exactly two states (simplicity): + +``` +┌────────────┐ prepare() ┌──────────┐ +│ unprepared │ ──────────> │ prepared │ +└────────────┘ └──────────┘ + ^ │ + │ mz() │ + └──────────────────────────┘ +``` + +- **unprepared**: Initial state, or after measurement +- **prepared**: Ready for gate operations + +Gates on unprepared slots are compile-time errors. + +#### 3. Comptime Metaprogramming + +```zlup_nocheck +// Parameterized surface code +pub fn surface_code(comptime distance: u32) -> type { + num_data := distance * distance; + num_ancilla := (distance - 1) * (distance - 1) * 2; + + return struct { + data: qalloc(num_data), + ancilla: qalloc(num_ancilla), + + // Self is implicitly available (Rust-style) + + pub fn init(base: *Alloc) -> Self { + return Self { + data: base.child(num_data), + ancilla: base.child(num_ancilla), + }; + } + + pub fn syndrome_round(&mut self) -> [num_ancilla]bit { + pz self.ancilla; + + // Compile-time loop unrolling + inline for i in 0..num_ancilla / 2 { + self.measure_x_stabilizer(i); + } + inline for i in 0..num_ancilla / 2 { + self.measure_z_stabilizer(i); + } + + // return mz([num_ancilla]u1) self.ancilla[..]; + } + }; +} + +// Usage +pub fn main() -> unit { + mut base := qalloc(50); + mut code := surface_code(3).init(&base); + + for _ in 0..1000 { + syndrome := code.syndrome_round(); + // decode and correct... + } +} +``` + +#### 4. Error Handling + +Zlup follows Zig's error-as-values philosophy with extensions for quantum error correction. +The design is **explicit over implicit** — you must acknowledge every potential error, +aligning with NASA Power of 10 requirements for checking all return values. + +##### Faults vs Errors + +Zlup distinguishes **faults** from **errors** based on their origin and typical handling pattern: + +| Aspect | Fault | Error | +|--------|-------|-------| +| **Origin** | Quantum hardware (physical layer) | Classical logic (software layer) | +| **Nature** | Expected imperfection we're designed to handle | Unexpected problem that means something is wrong | +| **Handling** | Collect for later analysis (QEC pattern) | Stop execution immediately | +| **Keyword** | `fault` | `error` | + +The distinction is not about severity — a fault can absolutely cause a logical error. Rather, +it's about **where the problem originates** and **how we typically want to handle it**: + +- **Faults happen at the physical layer.** The quantum hardware did something imperfect: + a gate didn't apply cleanly, a qubit leaked, a measurement was noisy. These are expected — + QEC is designed to handle a certain fault rate. Stopping on every fault would make + error correction impossible. A QEC round might collect 50 faults and that's fine; + that's what error correction is for. + +- **Errors happen at the logical layer.** Something in the classical algorithm went wrong: + the decoder couldn't find a valid correction, a file wasn't found, an invalid state was + reached. These indicate either a bug or an unrecoverable situation. If the decoder says + "I can't figure out a correction," that's an error — the logical algorithm failed. + +**Mental model:** +- Faults = "expected badness we're designed to handle" +- Errors = "unexpected badness that means something is wrong" + +**Quantum faults** — physical events from the quantum hardware: +```zlup +// Use `fault` keyword for quantum/physical faults +QuantumFault := fault { + Leakage, + QubitLoss, + GateFailure, +}; +``` + +**Classical errors** — logical problems in the software: +```zlup +// Use `error` keyword for classical/logical errors +DecodeError := error { + SyndromeAmbiguous, + WeightTooHigh, +}; + +IoError := error { + FileNotFound, + PermissionDenied, +}; +``` + +| Category | Keyword | Behavior in `try` | Behavior in `try!` | Examples | +|----------|---------|-------------------|-------------------|----------| +| Quantum Fault | `fault` | Collected, continues | Stops immediately | Leakage, qubit loss, gate failure | +| Classical Error | `error` | Stops immediately | Stops immediately | Decoder failure, I/O error, invalid state | + +##### Explicit Handling Required + +Unlike languages that allow exceptions to propagate silently or errors to be ignored, +Zlup requires **explicit handling of both faults and errors**. This aligns with NASA Power +of 10 Rule 7: "Check the return value of all non-void functions." + +**Learning from Rust's `?` operator criticism:** + +Rust has been criticized for making error propagation *too easy*. The `?` operator lets +developers mechanically propagate errors without thinking about them: + +```rust +// Rust: ? makes it easy to just bubble errors up without thought +fn do_something() -> Result { + let x = step1()?; // Just propagate, don't think + let y = step2()?; // Just propagate, don't think + let z = step3()?; // Just propagate, don't think + Ok(z) +} +``` + +While this is "explicit" in that you must write `?`, it becomes so automatic that +developers stop thinking about error handling. The errors bubble up, but nobody along +the way considered what to do about them. + +**Zlup's approach: deliberate handling over mechanical propagation** + +In QEC, errors and faults carry crucial diagnostic information. Mechanical propagation +loses context and makes debugging difficult. Zlup encourages *deliberate* handling: + +```zlup_nocheck +// INVALID: ignoring the return value +qec_round(q); // Compile error: unhandled faults/errors + +// DISCOURAGED: mechanical propagation without thought +// (Zlup intentionally doesn't have a `?` operator) + +// ENCOURAGED: deliberate handling with context +faults, result := qec_round(q); +result catch |err| { + log("QEC round failed at step {}: {}", step, err); + log("Faults before failure: {}", faults); + return err; // Propagate, but with added context +}; + +// VALID: explicitly discard if truly not needed +_ = qec_round(q); // Explicit discard - you meant to ignore it +``` + +The absence of a `?`-like operator is intentional. When you propagate an error, Zlup +wants you to think about it — add context, log diagnostics, or transform it. This +makes error handling visible, auditable, and meaningful rather than mechanical. + +**Why this matters for quantum computing:** + +In classical computing, a propagated error eventually reaches a handler somewhere. +In quantum computing, by the time an error surfaces, the quantum state may be +irretrievably corrupted. Understanding *where* and *why* things went wrong is +essential for: +- Debugging QEC implementations +- Tuning error thresholds +- Identifying systematic hardware issues +- Post-mortem analysis of failed computations + +Silent or mechanical error propagation loses this crucial information. + +##### Promoting Faults to Errors + +Sometimes accumulated faults cross a threshold where they should become a logical error. +Zlup supports **promoting faults to errors** when the situation warrants it: + +```zlup_nocheck +fn qec_round(q: []qubit) try -> ([]QuantumFault, QecError!Syndrome) { + // Run the circuit, collecting faults + faults, syndrome := run_stabilizers(q); + + // Too many faults? Promote to a classical error + if (faults.len > max_correctable) { + return error.TooManyFaults; // Stops execution, returns collected faults + } + + // Faults within tolerance - continue + return syndrome; +} + +// Caller receives both the faults that occurred AND the error/result +faults, result := qec_round(q); + +result catch |err| { + // err might be TooManyFaults - we still have access to `faults` + // to see what happened before the threshold was crossed + log("QEC failed with {} faults: {}", faults.len, err); + return; +}; +``` + +This pattern allows: +- **Graceful degradation**: Collect faults until a threshold, then fail cleanly +- **Diagnostic information**: Even on failure, you know what faults occurred +- **Policy flexibility**: Different QEC codes can set different thresholds + +The key insight: faults don't automatically become errors. Your code decides when +accumulated faults constitute a logical failure, making the policy explicit and tunable. + +##### Return Type Syntax + +```zlup_nocheck +// Single error union: either E or T (Zig style) +E!T + +// Quantum faults + classical errors with value (QEC pattern) +// Returns: ([]QuantumFault, ClassicalError!T) +([]QuantumFault, DecodeError!T) +``` + +##### Two Error Handling Modes + +**`try!` — Stop on First Error/Fault (Traditional/Strict)** + +Matches Zig/Rust semantics. Any error or fault stops execution immediately. + +```zlup_nocheck +fn strict_circuit(q: []qubit) try! -> QuantumFault!unit { + h q[0]; // if fault occurs, return immediately + cx (q[0], q[1]); // only runs if h succeeded +} + +// Caller handles single fault +strict_circuit(q) catch |fault| { + log("Failed: {} on qubit {}", fault.type, fault.qubit); +}; +``` + +**`try` — Collect Faults, Stop on Errors (QEC Pattern)** + +Quantum-specific extension: +- Quantum faults: collected into array, execution continues +- Classical errors: stops execution, returns collected faults + error + +##### Summary: Behavior by Mode + +| Mode | Quantum Fault | Classical Error | Return Type | +|------|---------------|-----------------|-------------| +| `try!` | **Stops immediately** | **Stops immediately** | `E!T` | +| `try` | Collected, continues | Stops, returns collected faults | `([]QuantumFault, ClassicalE!T)` | + +```zlup_nocheck +fn qec_round(q: []qubit) try -> ([]QuantumFault, DecodeError!Syndrome) { + // Quantum faults - collected, continues + cx (q[0], q[1]); // Leakage detected → recorded, keeps going + cx (q[1], q[2]); // QubitLoss detected → recorded, keeps going + + syndrome := mz([2]u1) [q[3], q[4]]; + + // Classical error - stops execution, returns collected faults + correction := decode(syndrome); // WeightTooHigh → STOP + + apply(correction, q); + return syndrome; +} +``` + +##### Caller Side + +```zlup_nocheck +faults, result := qec_round(q); + +// faults: []QuantumFault - all quantum faults detected during execution +// result: DecodeError!Syndrome - either error that stopped us, or final value + +result catch |err| { + log("Classical error: {}", err); + log("Quantum faults before failure: {}", faults); + return; +}; + +// Success path - unwrap result +syndrome := result.!; +if (faults.len > 0) { + // Faults may or may not have caused logical errors + corrections := analyze_faults(faults); + apply_corrections(q, corrections); +} +``` + +##### Block Syntax + +Error handling can also be scoped to blocks within functions: + +```zlup_nocheck +fn complex_circuit(q: []qubit) -> unit { + // Strict section - any error stops + try! { + prepare_logical_zero(q); + logical_h(q); + } catch |err| { + abort("Logical prep failed: {}", err); + } + + // QEC section - soft errors collected, hard errors stop + soft_errors, result := try { + cx (q[0], q[3]); + cx (q[1], q[3]); + syndrome := mz([2]u1) [q[3], q[4]]; + decode(syndrome) // hard error stops here + }; + + result catch |hard_err| { + log("Decode failed: {}", hard_err); + return; + }; + + if (soft_errors.len > 0) { + apply_corrections(q, soft_errors); + } +} +``` + +##### Rich Error Context + +Errors automatically carry context (compiler-injected): + +```zlup_nocheck +soft_errors, result := qec_round(q); + +for (soft_errors) |err| { + switch (err) { + .Leakage => |ctx| { + log("Leakage in {} on qubit {}", ctx.gate, ctx.qubit); + reset_qubit(q[ctx.qubit]); + }, + .QubitLoss => |ctx| { + log("Lost qubit {} during {}", ctx.qubit, ctx.gate); + flag_qubit_lost(ctx.qubit); + }, + else => log_error(err), + } +} +``` + +##### Classical-Only Functions + +Functions without quantum operations use standard Zig-style error handling: + +```zlup_nocheck +fn decode(syndrome: []const bit) -> DecodeError!Correction { + if (weight(syndrome) > threshold) { + return error.WeightTooHigh; + } + return compute_correction(syndrome); +} + +fn run_with_recovery(code: *SurfaceCode) -> unit { + syndrome := code.syndrome_round(); + + correction := decode(syndrome) catch |err| switch (err) { + error.WeightTooHigh => Correction.identity, + error.SyndromeAmbiguous => { + return run_with_recovery(code); + }, + }; + + apply(correction, code.data); +} +``` + +##### Explicit Returns Required + +Zlup requires the `return` keyword for all function returns. Unlike Rust, which uses +implicit trailing expressions (the last expression without a semicolon becomes the +return value), Zlup makes returns explicit: + +```zlup_nocheck +// INVALID in Zlup (valid in Rust): implicit return +fn add(a: i32, b: i32) -> i32 { + a + b // Rust would return this implicitly - Zlup requires explicit return +} + +// VALID in Zlup: explicit return +fn add(a: i32, b: i32) -> i32 { + return a + b; +} +``` + +**Why require explicit returns?** + +1. **Clarity of intent**: An explicit `return` makes it unambiguous that you intend + to exit the function with a value. In Rust, forgetting a semicolon can accidentally + change a statement into a return expression. + +2. **Consistency**: Every return looks the same, whether it's at the end of the function, + in the middle, or inside a conditional. No special rules for "trailing position." + +3. **NASA Power of 10 alignment**: Rule 1 emphasizes simple control flow. Explicit + returns make control flow obvious — you can grep for `return` to find all exit points. + +4. **Error handling clarity**: When combined with error handling, explicit returns + make it clear what value is being returned: + +```zlup_nocheck +fn qec_round(q: []qubit) try -> ([]QuantumFault, DecodeError!Syndrome) { + faults, syndrome := run_stabilizers(q); + + if (faults.len > threshold) { + return error.TooManyFaults; // Clearly returning an error + } + + return syndrome; // Clearly returning success value +} +``` + +5. **Auditable code**: In safety-critical quantum computing, code reviewers can easily + verify that every code path has an explicit return with the correct type. + +**Block expressions vs function returns:** + +Note that block expressions (like `if` expressions used for assignment) can still +evaluate to values — the explicit return requirement applies to *function* returns: + +```zlup_nocheck +// Block expression for assignment - this is fine +x := if (condition) { 42 } else { 0 }; + +// But function must use explicit return +fn get_value(condition: bool) -> i32 { + return if (condition) { 42 } else { 0 }; +} +``` + +**Unit functions require explicit return:** + +All functions must explicitly return their value, including unit functions. For unit +functions, `return;` is shorthand for `return unit;` and is the preferred style: + +```zlup_nocheck +// INVALID: unit function without return +fn do_work() -> unit { + process_data(); +} + +// VALID: unit function with explicit return +fn do_work() -> unit { + process_data(); + return; // Preferred: return; is shorthand for return unit; +} +``` + +This requirement serves several purposes: + +1. **Uniform control flow**: Every function has an explicit exit point, making code flow + analysis and review easier. + +2. **Prevent accidental fallthrough**: Without explicit returns, it's easy to forget that + control reaches the end of a function. With `return;`, you're forced to think about it. + +3. **NASA Power of 10 compliance**: All control flow is explicit. There's no implicit + "fall off the end" behavior. + +4. **Exception**: Only `never` functions (functions that never return normally, like + `panic()` or `abort()`) are exempt from this requirement. + +> **Note:** `return;` without a value is only valid in functions returning `unit`. +> Using `return;` in a function with a non-unit return type is a compile error. + +> **Implementation note:** The semantic analyzer should enforce that: +> 1. All functions have explicit `return` statements on all code paths +> 2. Trailing expressions in function bodies are not treated as implicit returns +> 3. Missing returns produce clear compile-time errors +> 4. `return;` is only allowed in unit-returning functions + +##### Design Rationale + +| Principle | Implementation | +|-----------|----------------| +| Explicit over implicit | Must use `try`/`try!`/`catch` — no silent error dropping | +| Explicit returns | Use `return` keyword, not implicit trailing expressions | +| NASA Power of 10 | All return values checked, errors are values | +| QEC-friendly | Quantum faults collected, classical errors stop execution | +| Faults vs Errors | `fault` for faults (physical), `error` for errors (logical) | +| Rich diagnostics | Faults/errors carry gate, qubit, and location context | +| Zig-aligned | `E!T` syntax, `catch` handling, error sets | + +#### 5. Module System + +```zlup_nocheck +// lib/qec/surface.zlp +std := @import("std"); + +pub Distance := enum(u32) { + d3 = 3, + d5 = 5, + d7 = 7, +}; + +pub SurfaceCode := struct { + distance: Distance, + data: Alloc, + ancilla: Alloc, + + pub fn init(base: *Alloc, distance: Distance) -> SurfaceCode { + d := @enumToInt(distance); + return .{ + distance, + data: base.child(d * d), + ancilla: base.child((d-1) * (d-1) * 2), + }; + } +}; + +// main.zlp +surface := @import("qec/surface.zlp"); + +pub fn main() -> unit { + mut base := qalloc(100); + mut code := surface.SurfaceCode.init(&base, .d5); + // ... +} +``` + +--- + +## Syntax Reference + +### Declarations + +Zlup uses `:=` (walrus operator) for type-inferred declarations and `: T =` for explicit types. + +```zlup_nocheck +// Constants (type inferred from value) +pi := 3.14159; // f64 inferred +num_qubits := 17; // integer inferred + +// Constants (explicit type) +pi: f64 = 3.14159; +num_qubits: u32 = 17; + +// Mutable variables +mut count := 0; // type inferred +mut count: u32 = 0; // explicit type +mut syndrome: [8]bit = undefined; + +// Type declarations (constructor makes type obvious) +Point := struct { x: f64, y: f64 }; +Color := enum { Red, Green, Blue }; +QubitError := error { Leakage, QubitLoss, GateFailure }; + +// Set literals +targets := set { q[0], q[1], q[2] }; + +// Public exports +pub Config := struct { ... }; +pub fn process() -> unit { ... } +``` + +#### Declaration Mental Model + +| Syntax | Meaning | +|--------|---------| +| `name := value` | Type inferred from value | +| `name: T = value` | Explicit type annotation | +| `mut name := value` | Mutable, type inferred | +| `mut name: T = value` | Mutable, explicit type | +| `Name := struct { }` | Type definition (constructor infers "type") | + +### Types + +```zlup_nocheck +// Primitives - arbitrary-width integers (1-128 bits, like Zig) +u1, u2, u3, ..., u128 // Unsigned N-bit integers +i1, i2, i3, ..., i128 // Signed N-bit integers +usize, isize // Pointer-sized integers +f32, f64 // Floats +a64 // Angle (backed by PECOS Angle64) +bool // Boolean +unit // Unit type (single value) + +// Quantum types +qubit // Single qubit (abstract) +bit // Classical bit (measurement result) +Alloc // Qubit allocator +qalloc(N) // Allocator with capacity N (comptime) +// Classical data uses standard array types: +[N]bit // Array of classical bits +[N]u8 // Array of bytes + +// Compound types +[N]T // Array of N elements of type T +[]T // Slice (runtime-sized view) +*T // Pointer to T +?T // Optional (T or none) +E!T // Error union (error E or value T) - Zig style +[]E!T // Collected errors plus value (QEC extension) +Set(T) // Unordered set of unique elements + +// User-defined +struct { ... } +enum { ... } +union(enum) { ... } +error { ... } // Error set definition +``` + +### Type Ascription + +Type ascription uses a space-separated postfix syntax, providing a clean and consistent way to specify types on expressions: + +```zlup +// Literal type ascription +x := 42 u32; // 42 as u32 +y := 100 i64; // 100 as i64 +pi := 3.14159 f64; // Float as f64 + +// Expression type ascription (evaluates, then converts) +half := 1/2 f64; // 0.5 (division yields float, then typed) +quarter := 1/4 f64; // 0.25 (non-exact division automatically floats) + +// Angle unit suffix (same pattern) +angle := 1/4 turns; // Quarter turn +theta := 3.14159 rad; // Radians +``` + +**Design rationale:** + +- **Unified syntax**: Type suffixes (`42 u32`), angle units (`1/4 turns`), and type ascription all use the same postfix pattern +- **Reads naturally**: "42 as u32", "one quarter turns" +- **No truncation surprises**: Integer division `1/4` produces `0.25` (float) when the result isn't exact, not `0` (truncated) +- **Explicit over implicit**: The type/unit is always visible at the expression site + +This differs from Zig's `@as(u32, 42)` builtin in favor of the more readable postfix form. + +### Angle Literals + +Angles require explicit units—no implicit radians or degrees: + +```zlup_nocheck +// Turns (native unit) - 1 turn = full rotation +rz(1/4 turns) q[0]; // Quarter turn +rz(1/8 turns) q[0]; // T gate (eighth turn) +rz(0.5 turns) q[0]; // Half turn (Z gate equivalent) + +// Radians (for those who prefer mathematical convention) +rz(std.f64.pi/4 rad) q[0]; // pi/4 radians = 1/8 turns +rz(std.f64.pi/2 rad) q[0]; // pi/2 radians = 1/4 turns + +// Fractions preferred over decimals (exact representation) +rz(1/4 turns) q[0]; // Exact +rz(0.25 turns) q[0]; // Also works, but 1/4 is clearer +``` + +**Design rationale:** + +- **Explicit units prevent bugs**: No confusion about radians vs degrees vs turns (Mars Climate Orbiter!) +- **Turns as native**: Common QEC angles are simple fractions (1/4, 1/8, 1/2) +- **Backed by PECOS Angle64**: Uses fixed-point representation that's exact for fractions of turns +- **Fractions encouraged**: `1/4 turns` is both more readable and more precise than `0.25 turns` + +**Angle64 Internal Representation:** + +The `a64` type uses a 64-bit fixed-point representation where the full range [0, 2^64) maps to [0, 1) turns: + +| Angle | Fixed-Point Value | Common Use | +|-------|------------------|------------| +| 0 turns | 0 | Identity | +| 1/8 turns | 2^61 | T-gate (π/4 rad) | +| 1/4 turns | 2^62 | S-gate (π/2 rad) | +| 1/2 turns | 2^63 | Z-gate (π rad) | + +This representation provides: +- **Exact arithmetic** for all dyadic fractions (powers of 2 in denominator) +- **Wrapping at full turn** via natural integer overflow +- **Efficient operations** using integer arithmetic +- **No floating-point precision issues** for common quantum angles + +Pre-defined constants in `zlup-ffi`: +```rust +Angle64::ZERO // 0 turns +Angle64::EIGHTH_TURN // 1/8 turns (T-gate) +Angle64::QUARTER_TURN // 1/4 turns (S-gate) +Angle64::HALF_TURN // 1/2 turns (Z-gate) +``` + +### Control Flow + +```zlup_nocheck +// Conditionals +if condition { + // ... +} else if other { + // ... +} else { + // ... +} + +// If as expression +max := if a > b { a } else { b }; + +// Bounded for loop (preferred - NASA Rule 2) +for i in 0..n { + process(i); +} + +// For with collection +for item in items { + use(item); +} + +// For with index (enumerate) +for i, item in items { + use(i, item); +} + +// Switch +switch (value) { + 0 => handle_zero(), + 1..10 => handle_small(), + else => handle_other(), +} + +// Labeled blocks (for break with value) +result := blk: { + if early_exit { break :blk default_value; } + break :blk computed_value; +}; +``` + +### Quantum Operations + +```zlup_nocheck +// Allocator operations +mut base := qalloc(100); // mut needed - will call .child() +data := base.child(9); // no mut needed - just applying gates +pz data; // Prepare all slots + +// Single-qubit gates (lowercase names) +h data[0]; +x data[1]; +z data[2]; + +// Parameterized gates with explicit angle units +rz(1/4 turns) data[0]; // Quarter turn (T² equivalent) +rz(1/8 turns) data[0]; // T gate angle +rx(1/4 turns) data[1]; // Quarter turn around X +rz(std.f64.pi/4 rad) data[0]; // Same as 1/8 turns, in radians + +// Two-qubit gates +cx (data[0], data[1]); +cz(ancilla[0], data[0]); + +// Batch operations with set semantics (unordered) +h { data[0], data[1], data[2] }; // Apply H to multiple qubits +cx { (data[0], data[1]), (data[2], data[3]) }; // Multiple CX gates + +// Typed measurements with array semantics (ordered) +r := mz(u1) data[0]; // Single qubit → u1 +results := mz([2]u1) [data[0], data[1]]; // Multiple qubits → [2]u1 (explicit size) +results := mz([4]u1) slice; // From slice (size must match) + +// Conditional operations +if r == 1 { + x data[1]; +} +``` + +#### Batch vs Array Semantics + +Zlup distinguishes between unordered and ordered operations: + +| Syntax | Semantics | Use Case | +|--------|-----------|----------| +| `{ }` | Set/unordered | Batch gates (order doesn't matter) | +| `[ ]` | Array/ordered | Measurements (result order matters) | + +```zlup_nocheck +// Batch gate: order doesn't matter, all applied "simultaneously" +h { q[0], q[1], q[2] }; + +// Measurement: order matters, results[0] corresponds to q[0] +results := mz([3]u1) [q[0], q[1], q[2]]; +``` + +### Tick Blocks (Parallel Layers) + +Tick blocks represent atomic time slices where operations execute in parallel. They act as **optimization barriers** - the optimizer cannot move operations across tick boundaries. + +```zlup_nocheck +// Basic tick block +tick { + h data[0]; + h data[1]; +} + +// Labeled tick +tick syndrome_round { + cx({(data[0], ancilla[0]), (data[1], ancilla[1])}); +} +``` + +**Note**: Nested tick blocks are disallowed. A tick represents an atomic time slice, and nesting would create ambiguity about timing semantics. Use sequential ticks instead: + +```zlup_nocheck +// Sequential ticks (correct) +tick layer1 { h({data[0], data[1]}); } +tick layer2 { cx({(data[0], data[2]), (data[1], data[3])}); } +``` + +### Attributes + +Metadata can be attached to ticks and gates: + +```zlup_fragment +// Single attribute +@attr(round, 0) +tick syndrome_check { + cx (data[0], ancilla[0]); +} + +// Multiple attributes +@attrs({round: 0, kind: "syndrome"}) +tick syndrome_check { + cx (data[0], ancilla[0]); +} + +// Gate attributes +@attrs({syndrome: "X", ancilla: true}) +cx (data[0], ancilla[0]); + +// Inline attributes on ticks +tick @attr(round, 1) layer1 { + h data[0]; +} +``` + +### Optimization Barriers + +Zlup provides several mechanisms to control optimization, particularly important for QEC where gate ordering and timing can affect error correction. + +#### Preserve Attributes + +These attributes prevent the optimizer from modifying or removing operations: + +| Attribute | Purpose | Use Case | +|-----------|---------|----------| +| `@preserve` | Prevent any optimization | Debugging, calibration sequences | +| `@timing` | Preserve timing relationships | Time-sensitive QEC protocols | +| `@identity` | Keep intentional identity operations | Noise characterization, benchmarking | +| `@noopt` | Disable all optimizations in scope | Development, debugging | + +```zlup_nocheck +// Prevent gate cancellation +@preserve +h q[0]; +@preserve +h q[0]; // Both H gates preserved, won't cancel + +// Preserve timing-critical sequence +@timing { + cx (data[0], ancilla[0]); + mz(u1) ancilla[0]; +} + +// Keep intentional identity +@identity { + x q[0]; + x q[0]; // Won't be optimized away +} + +// Disable optimization in block +@noopt { + h q[0]; + h q[0]; + z q[0]; // Nothing optimized +} +``` + +#### QEC Round Tracking + +The `@round(n)` attribute tracks QEC syndrome rounds, preventing gate cancellation across round boundaries: + +```zlup_nocheck +// Gates in different rounds won't cancel +@round(0) { + h ancilla[0]; + mz(u1) ancilla[0]; +} + +@round(1) { + h ancilla[0]; // Same gate, different round - won't cancel with round 0 + mz(u1) ancilla[0]; +} +``` + +**Design rationale**: In QEC, the same gate sequence may appear in multiple rounds, but each round's operations must execute independently. `@round(n)` prevents the optimizer from incorrectly combining gates across logical round boundaries. + +#### Tick Blocks as Barriers + +Tick blocks are always optimization barriers - operations cannot be moved across tick boundaries: + +```zlup_fragment +tick { + h q[0]; +} +// Optimizer cannot move this H into the tick above +h q[0]; +tick { + h q[0]; +} +``` + +This ensures that timing-critical sequences remain intact even when optimization is enabled. + +### Functions + +```zlup_nocheck +// Basic function +fn add(a: i32, b: i32) -> i32 { + return a + b; +} + +// Function with error return (Zig-style) +fn divide(a: f64, b: f64) -> error{DivByZero}!f64 { + if b == 0 { return error.DivByZero; } + return a / b; +} + +// Comptime parameters +fn make_array(comptime T: type, comptime N: usize) -> [N]T { + return [_]T{0} ** N; +} + +// Method syntax (Rust-style receivers) +Counter := struct { + value: u32, + + fn increment(&mut self) -> unit { + self.value += 1; + } +}; +``` + +### Error-Handling Functions + +```zlup_nocheck +// try! function: stop on first fault/error (traditional/strict) +fn strict_circuit(q: []qubit) try! -> QuantumFault!unit { + h q[0]; // if fault occurs, return immediately + cx (q[0], q[1]); // only runs if h succeeded +} + +// try! with return value +fn strict_measure(q: []qubit) try! -> QuantumFault![]u1 { + h q[0]; + mz([2]u1) [q[0], q[1]] +} + +// try function: collect faults, stop on errors (QEC pattern) +fn qec_round(q: []qubit) try -> []QuantumFault!unit { + cx (q[0], q[3]); // fault recorded, continues + cx (q[1], q[3]); // runs regardless of previous faults +} + +// try with return value +fn qec_measure(q: []qubit) try -> []QuantumFault!u1 { + cx (q[0], q[3]); + mz(u1) q[3] +} +``` + +### Error Handling + +```zlup_nocheck +// Catch single fault/error +strict_circuit(q) catch |fault| { + log("Fault: {}", fault); +}; + +// Destructure collected faults and value +faults, result := qec_measure(q); + +// Error blocks within functions +try! { + h q[0]; + cx (q[0], q[1]); +} catch |err| { + handle_error(err); +}; + +syndromes := try { + cx (q[0], q[3]); + cx (q[1], q[3]); +}; +``` + +--- + +## Implementation Architecture + +### Parsing Strategy: Recursive Descent + +We use recursive descent parsing rather than the visitor pattern for several reasons: + +1. **Simplicity**: Direct mapping from grammar to code +2. **Explicitness**: Clear control flow, no hidden dispatch +3. **Debuggability**: Easy to step through +4. **NASA Power of 10**: Predictable call structure + +```rust +// Example parser structure +impl Parser { + fn parse_program(&mut self) -> Result { + let mut decls = Vec::new(); + + while !self.at_end() { + decls.push(self.parse_top_level_decl()?); + } + + Ok(Program { declarations: decls }) + } + + fn parse_top_level_decl(&mut self) -> Result { + if self.check(Token::Const) { + self.parse_const_decl() + } else if self.check(Token::Var) { + self.parse_var_decl() + } else if self.check(Token::Fn) { + self.parse_fn_decl() + } else if self.check(Token::Struct) { + self.parse_struct_decl() + } else { + Err(self.error("expected declaration")) + } + } + + fn parse_statement(&mut self) -> Result { + // Direct dispatch based on current token + match self.current().kind { + Token::Const => self.parse_const_decl().map(Statement::Const), + Token::Var => self.parse_var_decl().map(Statement::Var), + Token::If => self.parse_if_stmt(), + Token::For => self.parse_for_stmt(), + Token::Return => self.parse_return_stmt(), + _ => self.parse_expr_stmt(), + } + } + + // ... etc +} +``` + +### Compilation Pipeline + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Zlup Source (.zlp) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Lexer (pest grammar → tokens) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Recursive Descent Parser → Zlup AST (Rust) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Semantic Analysis │ +│ - Type checking │ +│ - Allocator validation (capacity, lifecycle) │ +│ - Qubit state validation (unprepared/prepared) │ +│ - Comptime evaluation │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ +│ SLR-AST Path │ │ HUGR Path │ │ PHIR Path │ +│ (Python) │ │ (Experiments) │ │ (Simulators) │ +│ │ │ │ │ │ +│ Zlup AST │ │ Zlup AST │ │ Zlup AST │ +│ │ │ │ │ │ │ │ │ +│ ▼ │ │ ▼ │ │ ▼ │ +│ SLR-AST │ │ HUGR │ │ PHIR │ +│ │ │ │ │ │ │ │ │ +│ ▼ │ │ ▼ │ │ ▼ │ +│ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ +│ │ Guppy │ │ │ │ Hardware │ │ │ │ PECOS │ │ +│ │ codegen │ │ │ │ backends │ │ │ │ sims │ │ +│ │ QASM │ │ │ │ TKET2 │ │ │ │ │ │ +│ │ codegen │ │ │ └──────────┘ │ │ └──────────┘ │ +│ └──────────┘ │ │ │ │ │ +└───────────────────┘ └───────────────────┘ └───────────────────┘ +``` + +### HUGR vs PHIR: MLIR-Inspired IRs + +Both HUGR and PHIR are MLIR-inspired intermediate representations, but target different use cases: + +**HUGR (Hierarchical Unified Graph Representation)** +- Used by TKET2 compiler and Guppy +- Targets hardware/experimental backends +- Rich optimization framework +- Serializable for tool interop + +**PHIR (Program Hierarchical IR)** +- Targets simulator backends +- Optimized for simulation semantics +- Used by PECOS simulation engines + +Compiling to both enables: +1. **Hardware path**: Zlup → HUGR → TKET2 → Hardware +2. **Simulation path**: Zlup → PHIR → PECOS simulators +3. **Python path**: Zlup → SLR-AST → Guppy/QASM + +### AST Design (Rust) + +The Rust AST mirrors the Python SLR-AST for easy conversion: + +```rust +// src/ast.rs + +#[derive(Debug, Clone, PartialEq)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, + pub file: Option, +} + +#[derive(Debug, Clone)] +pub struct Program { + pub name: String, + pub declarations: Vec, + pub location: Option, +} + +#[derive(Debug, Clone)] +pub enum Declaration { + Const(ConstDecl), + Var(VarDecl), + Fn(FnDecl), + Struct(StructDecl), + Enum(EnumDecl), + Allocator(AllocatorDecl), +} + +#[derive(Debug, Clone)] +pub struct AllocatorDecl { + pub name: String, + pub capacity: u32, + pub parent: Option, + pub location: Option, +} + +#[derive(Debug, Clone)] +pub enum Statement { + Const(ConstDecl), + Var(VarDecl), + Assign(AssignStmt), + If(IfStmt), + While(WhileStmt), + For(ForStmt), + Return(ReturnStmt), + Break(BreakStmt), + Continue(ContinueStmt), + Defer(DeferStmt), + Block(Block), + Expr(ExprStmt), + + // Quantum operations + Gate(GateOp), + Prepare(PrepareOp), + Measure(MeasureOp), +} + +#[derive(Debug, Clone)] +pub struct GateOp { + pub kind: GateKind, + pub targets: Vec, + pub params: Vec, + pub location: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateKind { + // Single-qubit + X, Y, Z, H, T, Tdg, + SX, SY, SZ, SXdg, SYdg, SZdg, + RX, RY, RZ, + RXY1q, + + // Two-qubit + CX, CY, CZ, CH, + SXX, SYY, SZZ, SXXdg, SYYdg, SZZdg, + RZZ, + + // Face rotations + F, Fdg, F4, F4dg, +} + +// ... etc +``` + +--- + +## Example Programs + +### Hello Quantum World + +```zlup +// hello.zlp +pub fn main() -> unit { + q := qalloc(2); // no mut needed - just applying gates + pz q; + + // Create Bell state + h q[0]; + cx (q[0], q[1]); + + // Measure with explicit type + results := mz([2]u1) [q[0], q[1]]; + return; +} +``` + +### Teleportation Protocol + +```zlup_nocheck +// teleport.zlp +pub fn main() -> unit { + mut base := qalloc(10); + + // Prepare initial state + mut psi := base.child(1); + pz psi; + ry(0.7) psi[0]; // Some arbitrary state + + // Create EPR pair + mut epr := base.child(2); + pz epr; + h epr[0]; + cx (epr[0], epr[1]); + + // Bell measurement + cx (psi[0], epr[0]); + h psi[0]; + + m1 := mz(u1) psi[0]; + m2 := mz(u1) epr[0]; + + // Classical corrections + if m2 == 1 { x epr[1]; } + if m1 == 1 { z epr[1]; } + + // epr[1] now holds the teleported state +} +``` + +### Surface Code QEC + +```zlup_nocheck +// surface_qec.zlp +std := @import("std"); + +pub fn SurfaceCode(comptime distance: u32) -> type { + d := distance; + num_data := d * d; + num_x_ancilla := (d - 1) * d; + num_z_ancilla := d * (d - 1); + num_ancilla := num_x_ancilla + num_z_ancilla; + + return struct { + data: qalloc(num_data), + ancilla: qalloc(num_ancilla), + syndrome: [num_ancilla]bit = undefined, + + pub fn init(base: *Alloc) -> Self { + return .{ + data: base.child(num_data), + ancilla: base.child(num_ancilla), + syndrome: undefined, + }; + } + + pub fn prepare_logical_zero(&mut self) -> unit { + pz self.data; + // All data qubits start in |0⟩ + } + + pub fn syndrome_round(&mut self) -> unit { + pz self.ancilla; + + // X stabilizers + inline for i in 0..num_x_ancilla { + a := i; + h self.ancilla[a]; + + // Connect to neighboring data qubits + neighbors := comptime x_stabilizer_neighbors(i); + inline for n in neighbors { + cx (self.ancilla[a], self.data[n]); + } + + h self.ancilla[a]; + } + + // Z stabilizers + inline for i in 0..num_z_ancilla { + a := num_x_ancilla + i; + + neighbors := comptime z_stabilizer_neighbors(i); + inline for n in neighbors { + cx (self.data[n], self.ancilla[a]); + } + } + + // Measure all ancilla (typed measurement) + // self.syndrome = mz([num_ancilla]u1) self.ancilla[..]; + } + + pub fn decode_and_correct(&mut self) -> unit { + correction := mwpm_decode(self.syndrome); + apply_correction(self.data, correction); + } + + // Comptime helper functions + fn x_stabilizer_neighbors(comptime i: u32) -> [4]u32 { + // ... compute neighbors at compile time + } + + fn z_stabilizer_neighbors(comptime i: u32) -> [4]u32 { + // ... compute neighbors at compile time + } + }; +} + +pub fn main() -> unit { + mut base := qalloc(50); + mut code := SurfaceCode(3).init(&base); + + code.prepare_logical_zero(); + + // QEC rounds + for round in 0..1000 { + code.syndrome_round(); + code.decode_and_correct(); + + // Optional: inject errors for testing + if round % 100 == 0 { + x code.data[0]; // Inject X error + } + } + + // Final logical measurement + // logical_result := mz([num_data]u1) code.data[..]; +} +``` + +--- + +## Implementation Plan + +### Phase 1: Core Language (MVP) ✓ COMPLETE + +1. **Lexer/Parser**: Pest grammar → Rust AST ✓ +2. **Basic types**: Integers, bools, arrays, a64 angles ✓ +3. **Control flow**: if, for (bounded only, no while) ✓ +4. **Functions**: Basic fn declarations ✓ +5. **Quantum ops**: Gates, typed measurements, allocators ✓ +6. **Batch operations**: Set literals for parallel gates ✓ +7. **Tick blocks**: Parallel layers with labels (nesting disallowed) ✓ +8. **Attributes**: `@key(value)` metadata on ticks/gates ✓ +9. **Optimization**: Constant folding, dead code elimination, gate cancellation ✓ +10. **Optimization barriers**: `@preserve`, `@timing`, `@identity`, `@noopt`, `@round(n)` ✓ +11. **Build system**: `build.zlp` infrastructure with targets and steps ✓ + +### Phase 2: Type System ✓ COMPLETE + +1. **Type checking**: Basic static type analysis ✓ +2. **Allocator validation**: Capacity and lifecycle ✓ +3. **Qubit state tracking**: Compile-time state validation ✓ +4. **Error unions**: Zig-style `E!T` with `catch` and `.!` unwrap ✓ +5. **Collected errors**: QEC-style `[]E!T` for fault collection ✓ +6. **Try blocks**: `try { }` (collect) and `try! { }` (propagate) syntax ✓ +7. **Try functions**: `fn foo() try -> []E!T` syntax ✓ +8. **Fault sets**: Quantum-specific `fault { Leakage, QubitLoss }` ✓ + +### Phase 3: Comptime ✓ COMPLETE + +1. **Inline for loop unrolling**: `inline for i in 0..N { }` unrolls at compile time ✓ + - Semantic validation: errors for non-comptime ranges, break/continue in inline for + - Optimization pass: variable substitution, recursive unrolling for nested loops +2. **Advanced builtins**: Type reflection with snake_case naming ✓ + - `@type_info(T)` - returns structured type information + - `@field_names(T)` - returns array of struct field names + - `@enum_fields(T)` - returns array of enum variant names + - `@type_from_info(info)` - constructs type from TypeInfo struct +3. **Generic type instantiation**: Functions with comptime params get specialized ✓ + - `fn make_array(comptime T: type, comptime N: u32) -> [N]T` + - Automatic mangling and caching of instantiated functions +4. **Comptime function memoization**: Cache comptime function results ✓ + - Structural type serialization for cache keys (handles anonymous structs) + - Avoids redundant evaluation for same arguments + +### Phase 4: Integration (IN PROGRESS) + +1. **SLR-AST bridge**: Convert to Python AST ✓ +2. **PyO3 bindings**: Use from Python ✓ +3. **QASM codegen**: Direct QASM output ✓ +4. **HUGR codegen**: Direct HUGR output ✓ + +### Phase 5: Tooling (IN PROGRESS) + +1. **LSP server**: IDE support - see [IDE Setup Guide](ide-setup.md) ✓ +2. **Formatter**: `zlup fmt` for canonical code style ✓ +3. **Linter**: `zlup lint` with auto-fix capabilities ✓ +4. **Documentation generator**: From doc comments (planned) +5. **Test runner**: Built-in test support (planned) + +--- + +## Open Questions + +1. ~~**Recursion policy**: Disallow entirely (strict Power of 10) or allow with depth limits?~~ → Resolved: Recursion is unconditionally disallowed. This is fundamental to Zlup's "safe by constraint" memory model—not a strict mode option. Use `inline for` with comptime bounds or regular `for` with runtime bounds instead. Recursive algorithms should be implemented iteratively or in native Rust code called via FFI. + +2. ~~**Parallel blocks**: How to express `parallel { }` from SLR?~~ → Resolved: `tick { }` blocks + +3. ~~**Interop with Guppy**: Can we call Guppy functions from Zlup?~~ → Design doc: [future/guppy-compat.md](future/guppy-compat.md) + - Strategy: Guppy linter enforcing NASA Power of 10 constraints ("Reliable Guppy" subset) + - Mechanical conversion to Zlup when code passes lint + - The linter is valuable independently, even without Zlup adoption + +4. ~~**Standard library**: What should be in `@import("std")`?~~ → Design doc: [future/stdlib-design.md](future/stdlib-design.md) + - Modules: math, bits, mem, qec, testing, ffi + - Zig-style import semantics with Rust/Python syntax + - Comptime-first, bounded containers, QEC-focused + +5. ~~**Build system**: Zig uses `build.zig`, what should we use?~~ → Implemented in `src/build.rs` + - `build.zlp` - the build system IS the language + - Full comptime power for build configuration + - Integrated Rust FFI library building + - Supports targets, optimization levels, build steps, and `-Dname=value` options + +6. ~~**Quantum fault handling**: How to handle gate faults (leakage, loss, etc.)?~~ → Resolved: + - `try { }` collects quantum faults (QEC pattern), returns `([]QuantumFault, ClassicalE!T)` + - `try! { }` stops on first fault/error (traditional), returns `E!T` + - `fn foo() try -> ...` and `fn foo() try! -> E!T` function syntax + - `fault { }` for faults (physical), `error { }` for errors (logical) + - Faults/errors carry rich context (gate, qubits, location) + +--- + +## Summary + +Zlup is an experimental language combining Zig semantics with Rust/Python-flavored syntax. +Inspired by Rust's commitment to safety and Zig's demonstration that expressivity doesn't +require complexity or magic, it explores achieving powerful, safe programs through simplicity +and constraints. + +It complements Guppy by providing: + +- **Expressivity through simplicity**: Powerful programs without hidden behavior or magic +- **Safety through constraints**: A naturally safe language given its restrictions +- **Rust-native workflows**: Direct integration with PECOS's Rust simulation backends +- **QEC reliability**: NASA Power of 10 principles for the reliability and predictability + that large-scale quantum error correction demands + +Zlup bridges to Python via SLR-AST JSON and targets hardware via HUGR (shared with Guppy), +enabling interoperability across the PECOS ecosystem while Guppy remains the primary +user-facing quantum programming language. diff --git a/exp/zlup/docs/dev-notes.md b/exp/zlup/docs/dev-notes.md new file mode 100644 index 000000000..7193ff83c --- /dev/null +++ b/exp/zlup/docs/dev-notes.md @@ -0,0 +1,509 @@ +# Development Notes + +This document tracks recent development work on Zlup, implementation details, and context for contributors. Read this to understand recent changes, current test status, and suggested next tasks. + +## What is Zlup? + +Zlup is a quantum programming language with Zig-inspired semantics and Rust/Python-flavored syntax. It compiles to multiple backends (SLR-AST, HUGR; QASM and PHIR planned) and features: +- Static type checking with quantum-aware semantics +- Qubit state tracking (prepared/unprepared lifecycle) +- NASA Power of 10 compliance options (strict mode) +- Comptime evaluation +- Batch-oriented quantum gate API + +## Recent Work Completed + +### Error Handling Documentation (February 2026) + +Created comprehensive error handling tutorial at `docs/tutorial-error-handling.md`: +- Faults vs Errors distinction with practical explanations +- Error sets and fault sets definitions and usage +- Error union syntax (`E!T` and `[]E!T`) +- `try` (collect mode) vs `try!` (propagate mode) blocks +- Try functions with both modes +- The explicit handling philosophy (no `?` operator) +- Four practical QEC examples: + - Basic syndrome extraction + - Full QEC round with threshold + - Promoting faults to errors + - Rich fault context inspection +- Quick reference and best practices + +Updated `docs/tutorial.md` to reference the new detailed guide. + +### Alias MVP Implementation (February 2026) + +Added the `alias` keyword for creating safe slice views with overlap detection: + +**Grammar changes:** +- Added `alias` to reserved keywords +- Added `alias_stmt = { "alias" ~ identifier ~ ":=" ~ expr ~ ";" }` + +**AST changes:** +- Added `AliasBinding` struct with name, source, location +- Added `Stmt::Alias(AliasBinding)` variant + +**Semantic analysis:** +- Added `AliasInfo` struct for tracking alias metadata (name, source, range, location) +- Added `aliases: BTreeMap` to SemanticAnalyzer +- Implemented `analyze_alias()` with overlap detection +- Added `extract_alias_source_info()` for parsing slice expressions +- Added `ranges_overlap()` helper for range intersection testing +- New error types: `OverlappingAlias`, `AliasSourceNotSlice`, `AliasRangeNotComptime` + +**MVP scope:** +- Immutable aliases only (no `mut alias`) +- Static ranges only (must be comptime-evaluable) +- Error on any overlap (simpler than mutable-only rule) + +**Files modified:** +- `src/zluppy.pest` - grammar rules +- `src/ast.rs` - AliasBinding, Stmt::Alias +- `src/parser.rs` - parse_alias_stmt() +- `src/semantic.rs` - AliasInfo, analyze_alias(), overlap detection +- `src/pretty.rs` - print_alias_binding() +- `src/comptime.rs` - eval alias as source expression + +### Phase 3: Comptime Features (February 2026) + +Completed the full comptime implementation plan with four features: + +**1. Inline For Loop Unrolling:** +- `inline for i in 0..N { }` unrolls at compile time in `optimize.rs` +- Semantic validation in `semantic.rs`: + - `InlineForRangeNotComptime` - range must be comptime-evaluable + - `BreakInInlineFor` - break disallowed in inline for + - `ContinueInInlineFor` - continue disallowed in inline for +- Recursive unrolling for nested inline for loops +- Variable substitution replaces loop variable with concrete values + +**2. Advanced Builtins (snake_case naming):** +- `@type_info(T)` - returns TypeInfo struct with kind, name, fields, variants +- `@field_names(T)` - returns array of struct field name strings +- `@enum_fields(T)` - returns array of enum variant name strings +- `@type_from_info(info)` - constructs Type from TypeInfo (reverse of @type_info) +- Both snake_case and camelCase names supported; snake_case preferred + +**3. Generic Type Instantiation:** +- Functions with `comptime` params get specialized versions at call sites +- Cache: `generic_instantiations: BTreeMap<(String, String), String>` in SemanticAnalyzer +- Mangled names: `make_array__u32_4` for `make_array(u32, 4)` +- Original declarations stored for cloning and substitution + +**4. Comptime Function Memoization:** +- `memo_cache: BTreeMap<(String, String), ComptimeValue>` in ComptimeEvaluator +- Structural type serialization for cache keys (handles anonymous structs correctly) +- Avoids redundant evaluation when same function called with same args + +**Files modified:** +- `src/comptime.rs` - memoization, advanced builtins, TypeInfoKind enum +- `src/semantic.rs` - inline for validation, generic instantiation, error types +- `src/optimize.rs` - inline for unrolling pass +- `src/zluppy.pest` - `comptime_modifier` rule for parser +- `src/parser.rs` - comptime parameter detection + +### Error Handling Improvements (February 2026) + +Enhanced the error handling type system with several improvements: + +**Grammar Rule Ordering Fix:** +- Fixed parser ambiguity where `error_set_decl` and `fault_set_decl` were being parsed as bindings +- Reordered `top_level_decl` rules to try error/fault set declarations before general bindings +- Now `GateFaults := fault { ... }` correctly parses as `FaultSetDecl`, not `Binding` + +**Error/Fault Set Union Support:** +- Error sets can be combined with `|` operator: `IoError | NetworkError` +- Fault sets can be combined similarly: `GateFaults | MeasurementFaults` +- Unions combine variant names, deduplicating common variants +- Type checking verifies operands are compatible error/fault sets + +**Associated Data Types:** +- Error and fault variants can now have associated data types +- Example: `FileError := error { NotFound: struct { path: []u8 }, PermissionDenied };` +- Types are resolved during semantic analysis and stored in `Type::ErrorSet`/`Type::FaultSet` +- Imported modules store variant names without associated types (resolved locally) + +**Try Block Type Inference:** +- `try {}` (collect mode) returns `[]AnyError!T` - slice of error unions +- `try! {}` (propagate mode) returns `AnyError!T` - single error union +- With catch clause, the result type is based on the body type +- Uses `Type::AnyError` as a conservative error type (full inference is future work) + +### Parallelism Analysis Module (February 2026) + +Added `src/analysis.rs` providing constraint-based parallelism analysis: + +**Analysis Passes:** +- `AllocatorAnalysis`: Tracks allocator lifetimes and accessibility scopes +- `OperationTagger`: Tags each quantum operation with resources it touches +- `DependencyGraph`: Builds edges between dependent operations +- `parallel_layers()`: Extracts maximal independent operation sets + +**CLI Integration:** +- `zlup analyze program.zlp` - Analyze a program for parallelism +- `--format json` - Machine-readable output +- `--verbose` - Show dependency graph + +**Design Philosophy:** +Parallelism follows from type signatures and allocator ownership: +- No allocator param → pure classical → parallelizable with any quantum +- Different allocator params → different qubits → independent +- Scopes act as implicit barriers + +### Safe-by-Constraint Memory Model (February 2026) + +Implemented unconditional safety checks that don't require strict mode: + +**Escape Analysis:** +- Functions cannot return references/slices to local variables +- `check_no_local_escape()` runs on all return statements +- Parameters are safe (caller owns the data) + +**Recursion Prevention:** +- All recursion is rejected (direct and mutual) +- Tracking always enabled, not just in strict mode +- `RecursionTracker` with `enter_function()`/`exit_function()` + +**@swap Validation:** +- Requires exactly 2 pointer arguments +- Types must match +- Proper error messages + +### Slice Type System (February 2026) + +**Type Distinction:** +- `[N]T` = Array (fixed size, known at compile time) +- `[]T` = Slice (dynamic view into memory) +- These are distinct types - arrays don't implicitly coerce to slices + +**Slice Syntax:** +- `arr[0..5]` - slice from index 0 to 4 +- `arr[2..]` - slice from index 2 to end +- `arr[..5]` - slice from start to index 4 +- `arr[..]` - full slice (converts array to slice) + +**Re-slicing:** +- `slice[0..2]` on a slice returns a slice +- `slice[0]` on a slice returns the element type +- Chained slicing works: `arr[0..10][2..5][0..2]` + +**Implementation:** +- Parser: `parse_range_expr()` for range syntax +- Semantic: `[]T` parsed as `Type::Slice`, not `Type::Array { size: None }` +- Type checking: `is_slice_op` detection in Index expression + +### Gate Naming Cleanup (February 2026) + +**Removed:** +- `s` and `sdg` gate names (replaced with `sz`/`szdg` per PECOS naming conventions) +- `GateKind::S` and `GateKind::Sdg` from AST + +**Correct Names:** +- `sz` = S gate (sqrt of Z) +- `szdg` = S-dagger gate + +**Updated Files:** +- `ast.rs` - removed enum variants +- `parser.rs` - updated gate name mapping +- `semantic.rs` - updated get_gate_info() +- `optimize.rs` - updated cancellation rules +- All codegen files (slr.rs, qasm.rs, phir.rs) +- Documentation (syntax.md, tutorial.md, README.md) + +### Deterministic Compilation (February 2026) + +- Replaced `HashMap`/`HashSet` with `BTreeMap`/`BTreeSet` throughout +- Ensures consistent output order across compilations +- Files: semantic.rs, module.rs, comptime.rs, optimize.rs, linter.rs, build.rs, all codegens + +### Duplicate Qubit Detection in Measurements (February 2026) + +- Extended tick block duplicate detection to include `Expr::Measure` +- Measurements on the same qubit in parallel are now caught +- Added to `collect_qubit_ids_from_expr()` + +### Boolean Literal Parsing Fix + +Fixed a parser issue where boolean literals on the LEFT side of `and`/`or` operators failed to parse (e.g., `true and y` failed but `y and true` worked). + +**Root cause**: Pest's implicit `WHITESPACE` rule inserted whitespace matching between elements, which interfered with keyword-based operator matching when the left operand was a keyword-like literal (`true`, `false`). + +**Solution**: Created a compound-atomic `atom` rule in `zluppy.pest` to wrap leaf expressions (literals, identifiers) that don't recursively contain `expr`. This prevents implicit whitespace from interfering with keyword operator matching while keeping recursive expressions (like `paren_expr`) outside the compound-atomic context. + +**Files modified:** +- `zluppy.pest`: Added `atom` rule with compound-atomic modifier (`${ }`) +- `parser.rs`: Added `Rule::atom` handling +- `optimize.rs`: Removed outdated comments about the parsing limitation + +### Advanced Optimization Framework + +Added a comprehensive optimization framework with QEC-aware barriers: + +**Optimization passes in `src/optimize.rs`:** +- Constant folding (arithmetic, boolean, comparison expressions) +- Dead code elimination (unreachable code after return/break) +- Gate cancellation (self-inverse gates like H·H = I, X·X = I) + +**Optimization barriers for QEC:** +- `@preserve` - Prevent any optimization on marked operations +- `@timing` - Preserve timing relationships +- `@identity` - Keep intentional identity operations +- `@noopt` - Disable all optimizations in scope +- `@round(n)` - QEC round tracking, prevents cross-round gate cancellation + +**Tick blocks as barriers:** +- `tick {}` blocks always act as optimization barriers +- Operations cannot be moved across tick boundaries +- Nested tick blocks are now disallowed (semantic error) + +### Build System Infrastructure + +Added `build.zlp` infrastructure in `src/build.rs`: +- `Target` enum: x86_64, aarch64, wasm32, etc. +- `Optimize` enum: Debug, ReleaseSafe, ReleaseFast, ReleaseSmall +- `Build` struct with addExecutable, addLibrary, addTest, addStep methods +- `BuildRunner` for executing build steps +- Support for `-Dname=value` options + +### 1. Numeric Literal Type Suffixes +- Added support for type suffixes on numeric literals: `42u32`, `1.5f32`, `0xFF_u16` +- Files modified: `zluppy.pest`, `parser.rs`, `ast.rs`, `semantic.rs` +- Suffixes are parsed and used for type inference in expressions + +### 2. Strict Mode Qubit Duplicate Detection in Tick Blocks +- Added `DuplicateQubitInTick` error for detecting when the same qubit is used multiple times in a tick block +- Tick blocks represent parallel quantum operations - same qubit can't be targeted twice +- Only enforced in strict mode (NASA Power of 10 compliance) +- Added helper methods: `check_duplicate_qubits_in_tick()`, `collect_qubit_ids_from_stmt()`, `collect_qubit_ids_from_expr()` + +### 3. Break/Continue Loop Validation +- Added `loop_depth` tracking to `SemanticAnalyzer` +- Added `BreakContinueOutsideLoop` error for break/continue statements outside loops +- Properly tracks nested loop depth for for loops + +### 4. Type Inference from Range Expressions in For Loops +- Added `infer_for_range_type()` helper method +- For loops now infer the loop variable type from the range expression +- `for i in 0u32..10u32` correctly infers `i` as `u32` +- Supports both `ForRange::Range` and `ForRange::Collection` + +### 5. Comptime Evaluation of Array Sizes +- Array type sizes are now evaluated at compile time +- `[10]u32` correctly resolves to `Type::Array { size: Some(10) }` +- Uses `ComptimeEvaluator` for evaluation + +### 6. Const Propagation for Array Sizes +- Constants are now evaluated at comptime and stored in `comptime_values` HashMap +- Array sizes can reference values: `N := 10; mut arr: [N]u32` +- Supports chained references: `A := 4; B := A; mut arr: [B]u32` + +## Current Test Status + +All tests pass: +- 621 library tests (including alias, comptime, inline-for tests) +- 16 main binary tests +- 48 CLI integration tests +- 174 proptest integration tests +- 9 doctests +- **Total: 868 tests** + +## Remaining TODOs in Code + +### semantic.rs +1. ~~**Line ~1940**: `// TODO: Implement proper error set type`~~ **FIXED** + - ~~Error values currently return `Type::Unknown`~~ + - ~~Should track actual error set types for better type safety~~ + - Error values now properly return `Type::ErrorSet` with the correct error set name and variants + - Module-exported error sets now include variant names for proper lookup + - Type checking now verifies error value assignments match expected error union types + +2. **Line ~2835**: `// TODO: Extract actual function signature from AST` + - Imported module functions have empty signatures + - Should extract actual parameter and return types + +### parser.rs +1. **Line ~1855**: `// TODO: Parse struct body for type definition` + - Struct types used in declarations don't parse their full body + +2. ~~**Lines ~2228, ~2236**: `// TODO: parse sentinel for [*:0] pointers`~~ **FIXED** + - ~~Sentinel-terminated pointer syntax not fully implemented~~ + - Now supports arbitrary sentinel values: `[*:expr]T` for pointers, `[N:expr]T` for arrays + - Added grammar rule `pointer_prefix` for flexible sentinel parsing + - Examples: `[*:0]u8`, `[*:255]u8`, `[10:0]u8`, `[:0]u8` + +## Roadmap + +### Current State (February 2026) + +**Completed:** +- Phase 1 (Core Language): Parser, basic types, control flow, quantum ops ✓ +- Phase 2 (Type System): Type checking, allocator validation, error unions ✓ +- Phase 3 (Comptime): Inline for, advanced builtins, generic instantiation, memoization ✓ +- Alias MVP: Safe slice views with overlap detection ✓ +- Tooling: LSP, formatter, linter ✓ +- Codegens: SLR-AST, QASM, HUGR ✓ + +**In Progress:** +- Phase 4 (Integration): PyO3 bindings working, PHIR planned +- Phase 5 (Tooling): Doc generator and test runner planned + +### Immediate Priorities (Polish & Stability) + +1. **Parser error recovery** - Unknown gate names currently panic; should return proper errors + +2. **Module function signature extraction** - Extract actual function signatures from imported modules for better type checking + +3. **Improve type display names** - Better error messages for complex types (arrays, slices, functions) + +### Short-term (Feature Completion) + +4. **Array bounds checking** - Add compile-time bounds checking when array size is known + +5. **Documentation generator** - Generate docs from `///` comments + +6. **Built-in test runner** - `zlup test` command for running test blocks + +### Architectural Decision: Custom Gates + +The [Custom Gates Design](future/custom-gates-design.md) proposes a significant rethink of how gates work: + +- **All gates become target-provided** (including `h`, `cx`, `pz`, `mz`) +- **`std.gates` becomes declarations**, not built-ins +- **Composite gates** allow full subroutines (prep, measurement, control flow) +- **Compile-time target validation** against target gate sets +- **IDE support** via project config and `@import("target")` + +**Impact if pursued:** +- Changes how every quantum operation works +- Requires target definition system +- Affects IDE/LSP significantly +- More flexible but more explicit (requires imports) + +**Decision needed:** Is this the right direction? If yes, it becomes high priority and affects subsequent work. + +### Medium-term (Post-Decision) + +If custom gates design is adopted: +- Implement target definition system +- Refactor `std.gates` as declarations +- Update IDE/LSP for target awareness +- Update all examples and docs + +If not adopted: +- Continue with current built-in gate model +- Focus on PHIR codegen +- Guppy compatibility work + +### Longer-term + +- **PHIR codegen** - For PECOS simulator targeting +- **Guppy compatibility** - Linter for "Reliable Guppy" subset, mechanical conversion +- **Full stdlib** - math, bits, mem, qec, testing, ffi modules + +### Lower Priority (Nice to Have) + +- **Array-to-slice coercion** - Consider implicit array→slice in function arguments +- **Struct body parsing** - Parse full struct bodies when used as types + +## Completed Features + +### FFI Support (External Functions) +- Added `extern "C" fn name(params) -> type;` syntax for declaring external functions +- Supports calling conventions: `"C"` (C ABI), `"Rust"` (Rust ABI) +- Library linking via `@link("libname")` attribute +- Useful for integrating classical decoders (MWPM, Union-Find, etc.) +- Grammar: `extern_fn_decl` in `zluppy.pest` +- AST: `ExternFnDecl` struct in `ast.rs` +- SLR codegen generates `ExternDecl` and `ExternCall` nodes +- C-compatible type mapping: primitives (`u8`, `u32`, etc.), pointers (`[*]T`), arrays + +### Gate Extensions +- Added SWAP, ISWAP, and CCX (Toffoli) gates to `GateKind` enum +- Updated SLR codegen to support new gates +- Three-qubit gate support (CCX has arity 3) + +## Key Files + +| File | Purpose | +|------|---------| +| `src/zluppy.pest` | PEG grammar definition | +| `src/parser.rs` | Parser implementation | +| `src/ast.rs` | AST node definitions | +| `src/semantic.rs` | Semantic analysis, type checking, symbol table | +| `src/comptime.rs` | Compile-time evaluation | +| `src/optimize.rs` | AST optimization passes (constant folding, DCE, gate cancellation) | +| `src/analysis.rs` | Parallelism analysis (allocators, dependencies, layers) | +| `src/build.rs` | Build system infrastructure for `build.zlp` | +| `src/codegen/slr.rs` | SLR-AST code generation | +| `src/codegen/qasm.rs` | OpenQASM code generation | +| `src/codegen/hugr.rs` | HUGR code generation | +| `src/main.rs` | CLI entry point | + +## Building and Testing + +```bash +# Build with CLI features +cargo build --features cli + +# Run all tests +cargo test --features cli + +# Run specific test +cargo test --features cli test_const_propagation +``` + +## Quantum Gate API + +The API uses DSL-style syntax (gate followed by target) with batch operations using set literals: +```zlup_nocheck +// Single-qubit gates (space-separated syntax) +h q[0]; +x q[1]; +rz(pi/4) q[0]; + +// Batch operations with set literals +h {q[0], q[1], q[2]}; +cx {(q[0], q[1]), (q[2], q[3])}; + +// Typed measurements +r: u1 = mz(u1) q[0]; // Single qubit +results: [2]u1 = mz([2]u1) [q[0], q[1]]; // Multiple qubits -> [2]u1 +syndrome: u8 = mz(pack u8) [q[0], q[1], ...]; // Pack into integer +``` + +## Example Program + +```zlup +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Apply Hadamard to all qubits + h {q[0], q[1], q[2], q[3]}; + + // Create entanglement + cx {(q[0], q[1]), (q[2], q[3])}; + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + return; +} +``` + +## Important Notes for Future Sessions + +### Variable Naming +Single-letter names like `s`, `h`, `x`, `y`, `z`, `t` are gate names. When writing tests or examples with slice/array parameters, use names like `data`, `arr`, `items` to avoid parsing conflicts. + +### Type System Key Points +- `[]T` and `[N]T` are distinct types +- Use `arr[..]` to convert array to slice +- Escape analysis prevents returning slices of local variables +- Parameters are safe to slice and return (caller owns data) + +### Safety Model +The language is "safe by constraint" - no recursion, no dangling references, no escaping locals. These checks are always enabled, not just in strict mode. + +--- +*Last updated: February 2026* diff --git a/exp/zlup/docs/errors.md b/exp/zlup/docs/errors.md new file mode 100644 index 000000000..8e159c56f --- /dev/null +++ b/exp/zlup/docs/errors.md @@ -0,0 +1,581 @@ +# Zlup Error Messages Guide + +This guide explains common error messages and how to fix them. + +## Parse Errors + +Parse errors occur when the code doesn't match the expected syntax. + +### "expected an identifier" + +**Cause:** A name (variable, function, type) was expected but something else was found. + +```zlup_nocheck +// Bad +fn 123() -> unit { } // Numbers can't be function names + +// Good +fn my_func() -> unit { } +``` + +### "expected a type" + +**Cause:** A type annotation was expected after `:`. + +```zlup_nocheck +// Bad +x: = 42; // Missing type + +// Good +x := 42; // Infer type +x: u32 = 42; // Explicit type +``` + +### "expected ';'" + +**Cause:** Statements must end with semicolons. + +```zlup_nocheck +// Bad +x := 42 +y := 10 + +// Good +x := 42; +y := 10; +``` + +### "expected '{'" + +**Cause:** Blocks require curly braces. + +```zlup_nocheck +// Bad +if condition + do_something(); + +// Good +if condition { + do_something(); +} +``` + +### "expected ')'" / "expected ']'" + +**Cause:** Unmatched parentheses or brackets. + +```zlup_nocheck +// Bad +result := add(1, 2; +array := [1, 2, 3; + +// Good +result := add(1, 2); +array := [1, 2, 3]; +``` + +### "expected ':=' for binding or '=' for assignment" + +**Cause:** Incorrect binding syntax. + +```zlup_nocheck +// Bad +x = 42; // This is assignment, not binding + +// Good +x := 42; // Binding (creates new variable) +mut y := 0; +y = 42; // Assignment (to existing variable) +``` + +### "expected function parameter (name: Type)" + +**Cause:** Function parameters must have names and types. + +```zlup_nocheck +// Bad +fn add(u32, u32) -> u32 { } + +// Good +fn add(a: u32, b: u32) -> u32 { } +``` + +### "expected '-> T' return type" + +**Cause:** Function return type syntax uses arrow. + +```zlup_nocheck +// Bad +fn add(a: u32, b: u32) u32 { } + +// Good +fn add(a: u32, b: u32) -> u32 { } +``` + +--- + +## Semantic Errors + +Semantic errors occur when code is syntactically valid but logically incorrect. + +### "undefined symbol 'name'" + +**Cause:** Using a variable, function, or type that hasn't been declared. + +```zlup_nocheck +// Bad +fn main() -> unit { + y := x + 1; // x not defined + return; +} + +// Good +fn main() -> unit { + x := 10; + y := x + 1; + return; +} +``` + +### "symbol 'name' already defined" + +**Cause:** Declaring the same name twice in the same scope. + +```zlup_nocheck +// Bad +x := 1; +x := 2; // Can't redeclare + +// Good +x := 1; +y := 2; // Different name + +// Or use mut for reassignment +mut x := 1; +x = 2; // Assignment, not redeclaration +``` + +### "cannot assign to immutable variable 'name'" + +**Cause:** Trying to reassign a variable that wasn't declared with `mut`. + +```zlup_nocheck +// Bad +x := 1; +x = 2; // x is immutable! + +// Good +mut x := 1; +x = 2; // x is mutable +``` + +### "unsafe blocks are forbidden" + +**Cause:** Using an `unsafe` block without the `--allow-unsafe` flag. + +```zlup_nocheck +// This will fail without --allow-unsafe +fn example() -> unit { + unsafe { + // ... + } + return; +} +``` + +**Solution:** Either: +1. Remove the unsafe block and rewrite using safe constructs +2. Compile with `--allow-unsafe` flag (for development/testing) + +Production code should avoid unsafe blocks. They exist as an escape hatch for expert use cases. + +### "type mismatch: expected T, found U" + +**Cause:** Using a value of the wrong type. + +```zlup_nocheck +// Bad +x: u32 = "hello"; // String is not u32 + +// Good +x: u32 = 42; +s: []const u8 = "hello"; +``` + +### "cannot infer type for 'name'" + +**Cause:** Type cannot be determined from context. + +```zlup_nocheck +// Bad +x := undefined; // What type? + +// Good +x: u32 = undefined; // Explicit type +``` + +--- + +## Gate Errors + +### "gate 'G' requires N qubits, got M" + +**Cause:** Wrong number of qubits for the gate. + +```zlup_nocheck +// Bad - too few qubits +cx q[0]; // CX needs 2 qubits +ccx (q[0], q[1]); // CCX (Toffoli) needs 3 qubits + +// Bad - too many qubits +h (q[0], q[1]); // H is single-qubit gate + +// Good +h q[0]; // Single qubit +cx (q[0], q[1]); // Two qubits (control, target) +ccx (q[0], q[1], q[2]); // Three qubits (Toffoli) +``` + +### "ambiguous target for multi-qubit gate" + +**Cause:** Multi-qubit gates need explicit tuple or set syntax. + +```zlup_nocheck +// Bad +cx q; // Which qubits? + +// Good +cx (q[0], q[1]); // Explicit pair +cx {(q[0], q[1]), (q[2], q[3])}; // Batch +``` + +### "invalid gate syntax: use 'gate target' instead" + +**Cause:** Gates use space-separated syntax, not function call syntax. + +```zlup_nocheck +// Bad (old syntax) +h(q[0]); +cx(q[0], q[1]); + +// Good (current syntax) +h q[0]; +cx (q[0], q[1]); +``` + +--- + +## Qubit Errors + +### "qubit 'alloc[i]' is not prepared" + +**Cause:** Using a qubit before preparing it, or after measurement without re-preparing. + +```zlup_nocheck +// Bad - never prepared +q := qalloc(2); +h q[0]; // Qubit not prepared! + +// Bad - used after measurement +q := qalloc(2); +pz q; +h q[0]; +r := mz(u1) q[0]; // Measurement resets qubit state +h q[0]; // Error: qubit no longer prepared! + +// Good - prepare before use +q := qalloc(2); +pz q; // Prepare first +h q[0]; + +// Good - re-prepare after measurement +q := qalloc(2); +pz q; +h q[0]; +r := mz(u1) q[0]; +pz q[0]; // Re-prepare after measurement +h q[0]; // Now OK +``` + +### "qubit 'alloc[i]' is already prepared" + +**Cause:** Preparing an already-prepared qubit (in strict mode). + +```zlup_nocheck +// Bad +pz q[0]; +pz q[0]; // Already prepared + +// Good +pz q[0]; +// ... use qubit ... +// Reset only if needed +``` + +### "qubit index N out of bounds for allocator (capacity: M)" + +**Cause:** Accessing a qubit index beyond the allocator's capacity. + +```zlup_nocheck +// Bad +q := qalloc(4); +h q[10]; // Only 0-3 available + +// Good +q := qalloc(4); +h q[3]; // Index 0-3 valid +``` + +### "cannot call .child() on immutable allocator" + +**Cause:** Parent allocator must be mutable to create children. + +```zlup_nocheck +// Bad +q := qalloc(10); +data := q.child(5); // q is immutable + +// Good +mut q := qalloc(10); // Make mutable +data := q.child(5); +``` + +### "qubit used multiple times within tick block" + +**Cause:** Same qubit appears in multiple operations within one tick. + +```zlup_nocheck +// Bad +tick { + h q[0]; + x q[0]; // Same qubit! +} + +// Good +tick { h q[0]; } +tick { x q[0]; } // Different ticks +``` + +--- + +## Measurement Errors + +### "invalid measurement type" + +**Cause:** Measurement type must be u1, u8, u64, or arrays thereof. + +```zlup_nocheck +// Bad +r: f64 = mz(f64) q[0]; // Float not valid + +// Good +r: u1 = mz(u1) q[0]; +``` + +### "measurement type mismatch: declared [N]T but measuring M qubits" + +**Cause:** Array size doesn't match number of qubits. + +```zlup_nocheck +// Bad +r: [4]u1 = mz([4]u1) [q[0], q[1]]; // Only 2 qubits + +// Good +r: [2]u1 = mz([2]u1) [q[0], q[1]]; +``` + +### "single qubit measurement requires scalar type" + +**Cause:** Measuring one qubit requires u1 (or similar), not array. + +```zlup_nocheck +// Bad +r: [1]u1 = mz([1]u1) q[0]; // Single qubit + +// Good +r: u1 = mz(u1) q[0]; +``` + +### "multiple qubit measurement requires array type" + +**Cause:** Measuring multiple qubits requires array type. + +```zlup_nocheck +// Bad +r: u1 = mz(u1) [q[0], q[1]]; // Multiple qubits + +// Good +r: [2]u1 = mz([2]u1) [q[0], q[1]]; +``` + +### "pack mode: type T has N bits but measuring M qubits" + +**Cause:** Pack type doesn't have enough bits. + +```zlup_nocheck +// Bad +r: u4 = mz(pack u4) [q[0], q[1], q[2], q[3], q[4]]; // 5 qubits, only 4 bits + +// Good +r: u8 = mz(pack u8) [q[0], q[1], q[2], q[3], q[4]]; // 8 bits >= 5 qubits +``` + +--- + +## Control Flow Errors + +### "unbounded loop detected" + +**Cause:** Loops must have bounded iteration (NASA Power of 10). + +```zlup_nocheck +// Bad +while condition { } // while loops not allowed + +// Good +for i in 0..100 { // Bounded iteration + if !condition { break; } +} +``` + +### "loop bound too large" + +**Cause:** Loop iteration count exceeds maximum (in strict mode). + +```zlup_nocheck +// Bad (if max is 1000) +for i in 0..1000000 { } + +// Good +for i in 0..1000 { } +``` + +### "recursion detected in function" + +**Cause:** Recursive calls are not allowed in strict mode (NASA Power of 10). + +```zlup_nocheck +// Bad +fn factorial(n: u32) -> u32 { + if n <= 1 { return 1; } + return n * factorial(n - 1); // Recursion! +} + +// Good - use iteration +fn factorial(n: u32) -> u32 { + mut result: u32 = 1; + for i in 1..n+1 { + result *= i; + } + return result; +} + +// Alternative - use unsafe block (requires --allow-unsafe) +fn factorial(n: u32) -> u32 { + unsafe { + if n <= 1 { return 1; } + return n * factorial(n - 1); + } +} +``` + +### "mutual recursion detected" + +**Cause:** Two or more functions call each other, forming a cycle. + +```zlup_nocheck +// Bad +fn is_even(n: u32) -> bool { + if n == 0 { return true; } + return is_odd(n - 1); // Calls is_odd +} + +fn is_odd(n: u32) -> bool { + if n == 0 { return false; } + return is_even(n - 1); // Calls is_even - cycle! +} + +// Good - use iteration or combine into one function +fn is_even(n: u32) -> bool { + return n % 2 == 0; +} +``` + +### "break outside of loop" / "continue outside of loop" + +**Cause:** Using break/continue outside a loop context. + +```zlup_nocheck +// Bad +fn main() -> unit { + break; // Not in a loop + return; +} + +// Good +fn main() -> unit { + for i in 0..10 { + if i == 5 { break; } + } + return; +} +``` + +### "missing return statement" + +**Cause:** Function doesn't return on all paths. + +```zlup_nocheck +// Bad +fn get_value(x: bool) -> u32 { + if x { + return 1; + } + // Missing return for else case! +} + +// Good +fn get_value(x: bool) -> u32 { + if x { + return 1; + } else { + return 0; + } +} +``` + +--- + +## Module Errors + +### "module not found: name" + +**Cause:** Imported module doesn't exist or isn't in search path. + +```zlup_nocheck +// Bad +utils := @import("nonexistent.zlup"); + +// Solutions: +// 1. Check file exists +// 2. Set ZLUP_STDLIB_PATH for std imports +// 3. Check relative path is correct +``` + +--- + +## Tips for Debugging + +1. **Read the full error message** - It often includes the exact location and suggestion. + +2. **Check the line number** - The error points to where the problem was detected, which may be after where it was caused. + +3. **Look for typos** - Common issues: `:=` vs `=`, missing semicolons, wrong brackets. + +4. **Use `zlup check`** - Runs semantic analysis without full compilation. + +5. **Try `zlup eval`** - Test small expressions interactively. + +6. **Enable verbose mode** - Some commands have `--verbose` for more details. + +7. **Simplify** - If you can't find the error, try removing code until it compiles, then add back piece by piece. diff --git a/exp/zlup/docs/future/alias-ast-sketch.rs b/exp/zlup/docs/future/alias-ast-sketch.rs new file mode 100644 index 000000000..64cbf982c --- /dev/null +++ b/exp/zlup/docs/future/alias-ast-sketch.rs @@ -0,0 +1,294 @@ +// ============================================================================= +// AST Sketch for Alias Feature +// ============================================================================= +// This is a design sketch, not actual implementation code. + +/// Alias binding - creates a named view into existing data. +/// +/// Syntax: +/// - `alias name := source;` - immutable alias +/// - `mut alias name := source;` - mutable alias (can be reassigned) +/// - `alias { a := x, b := y, ... }` - grouped aliases +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasBinding { + /// Name of the alias + pub name: String, + /// The source expression (typically a slice expression) + pub source: Expr, + /// Whether this alias can be reassigned to point elsewhere + pub is_mutable: bool, + /// Optional type annotation + pub ty: Option, + /// Documentation comment + pub doc_comment: Option, + pub location: Option, +} + +/// Grouped alias declaration for partitioning. +/// +/// Syntax: +/// ```zlup +/// alias { +/// data := q[0..4], +/// ancilla := q[4..8], +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasGroup { + /// The aliases in this group (checked for non-overlap) + pub aliases: Vec, + pub location: Option, +} + +// In Stmt enum, add: +pub enum Stmt { + // ... existing variants ... + + /// Single alias binding + Alias(AliasBinding), + + /// Grouped aliases (enables overlap checking within group) + AliasGroup(AliasGroup), +} + +// ============================================================================= +// Semantic Analysis Additions +// ============================================================================= + +/// Information about an alias tracked during semantic analysis. +#[derive(Debug, Clone)] +pub struct AliasInfo { + /// Name of the alias + pub name: String, + /// Name of the source variable + pub source_var: String, + /// Static range if known at compile time + pub static_range: Option, + /// Whether this alias is mutable + pub is_mutable: bool, + /// The type of elements in the alias + pub element_type: Type, + /// Location for error reporting + pub location: Option, +} + +/// A compile-time known range. +#[derive(Debug, Clone)] +pub struct StaticRange { + pub start: i128, + pub end: i128, // exclusive +} + +impl StaticRange { + pub fn overlaps(&self, other: &StaticRange) -> bool { + self.start < other.end && other.start < self.end + } + + pub fn len(&self) -> i128 { + self.end - self.start + } +} + +// Add to SymbolKind enum: +pub enum SymbolKind { + // ... existing variants ... + + /// An alias (named view into another variable) + Alias { + /// Type of the alias (slice type) + ty: Type, + /// The source variable this aliases + source: String, + /// Static range if known + range: Option, + /// Whether the alias binding is mutable + is_mutable: bool, + }, +} + +// ============================================================================= +// Overlap Checking +// ============================================================================= + +impl SemanticAnalyzer { + /// Check if a new alias overlaps with existing aliases to the same source. + fn check_alias_overlap(&self, new_alias: &AliasInfo) -> SemanticResult<()> { + // Find all existing aliases to the same source + for (name, symbol) in self.iter_symbols() { + if let SymbolKind::Alias { source, range, is_mutable, .. } = &symbol.kind { + if source == &new_alias.source_var { + // Same source - check for overlap + if let (Some(existing_range), Some(new_range)) = (range, &new_alias.static_range) { + if existing_range.overlaps(new_range) { + // Overlap detected + if *is_mutable || new_alias.is_mutable { + // At least one is mutable - error + return Err(SemanticError::OverlappingMutableAlias { + new_alias: new_alias.name.clone(), + existing_alias: name.clone(), + source: new_alias.source_var.clone(), + location: new_alias.location.clone().unwrap_or_default(), + }); + } + // Both immutable - could warn but allow + } + } + } + } + } + Ok(()) + } + + /// Analyze an alias binding. + fn analyze_alias(&mut self, alias: &AliasBinding) -> SemanticResult<()> { + // 1. Analyze the source expression + let source_ty = self.analyze_expr(&alias.source)?; + + // 2. Verify source is slice-able + let element_ty = match &source_ty { + Type::Slice { element, .. } => *element.clone(), + Type::Array { element, .. } => *element.clone(), + _ => { + return Err(SemanticError::TypeMismatch { + expected: "slice or array".to_string(), + found: source_ty.display_name(), + location: alias.location.clone().unwrap_or_default(), + }); + } + }; + + // 3. Extract source variable name and range (if static) + let (source_var, static_range) = self.extract_alias_source_info(&alias.source)?; + + // 4. Check for overlaps with existing aliases + let alias_info = AliasInfo { + name: alias.name.clone(), + source_var: source_var.clone(), + static_range: static_range.clone(), + is_mutable: alias.is_mutable, + element_type: element_ty.clone(), + location: alias.location.clone(), + }; + self.check_alias_overlap(&alias_info)?; + + // 5. Define the alias in the symbol table + let alias_ty = Type::Slice { + element: Box::new(element_ty), + is_mut: alias.is_mutable, + }; + + self.symbols.define(Symbol { + name: alias.name.clone(), + kind: SymbolKind::Alias { + ty: alias_ty, + source: source_var, + range: static_range, + is_mutable: alias.is_mutable, + }, + location: alias.location.clone(), + })?; + + Ok(()) + } + + /// Extract source variable and static range from a slice expression. + fn extract_alias_source_info(&self, expr: &Expr) -> SemanticResult<(String, Option)> { + match expr { + Expr::Slice(slice) => { + // Get the base variable name + let source_var = match &*slice.base { + Expr::Ident(ident) => ident.name.clone(), + _ => return Ok(("".to_string(), None)), + }; + + // Try to evaluate range bounds at compile time + let start = self.try_eval_comptime_int(&slice.start); + let end = self.try_eval_comptime_int(&slice.end); + + let static_range = match (start, end) { + (Some(s), Some(e)) => Some(StaticRange { start: s, end: e }), + _ => None, + }; + + Ok((source_var, static_range)) + } + Expr::Ident(ident) => { + // Aliasing entire variable - no range restriction + Ok((ident.name.clone(), None)) + } + _ => Ok(("".to_string(), None)), + } + } +} + +// ============================================================================= +// New Semantic Error Variant +// ============================================================================= + +pub enum SemanticError { + // ... existing variants ... + + #[error("mutable alias '{new_alias}' overlaps with '{existing_alias}' (both alias '{source}')")] + OverlappingMutableAlias { + new_alias: String, + existing_alias: String, + source: String, + location: SourceLocation, + }, + + #[error("alias '{name}' would outlive its source")] + AliasOutlivesSource { + name: String, + location: SourceLocation, + }, +} + +// ============================================================================= +// Parser Grammar Sketch (PEG) +// ============================================================================= + +/* +// Add to statement rule: +statement = { + // ... existing ... + | alias_stmt + | alias_group +} + +alias_stmt = { + mut_modifier? ~ "alias" ~ ws ~ identifier ~ ws ~ + (":" ~ ws ~ type_expr ~ ws)? ~ + ":=" ~ ws ~ expr ~ ";" +} + +alias_group = { + "alias" ~ ws ~ "{" ~ ws ~ + (alias_item ~ ("," ~ ws ~ alias_item)* ~ ","?)? ~ + ws ~ "}" +} + +alias_item = { + identifier ~ ws ~ (":" ~ ws ~ type_expr ~ ws)? ~ ":=" ~ ws ~ expr +} + +mut_modifier = { "mut" ~ ws } +*/ + +// ============================================================================= +// Code Generation (compiles to slice) +// ============================================================================= + +// Aliases compile directly to slice references - no runtime overhead. +// The alias keyword is purely for semantic analysis and safety checking. + +impl CodeGen { + fn gen_alias(&mut self, alias: &AliasBinding) -> Result<()> { + // Generate code for the source expression (produces a slice) + let source_value = self.gen_expr(&alias.source)?; + + // Bind the slice to the alias name + self.define_local(&alias.name, source_value); + + Ok(()) + } +} diff --git a/exp/zlup/docs/future/alias-design.md b/exp/zlup/docs/future/alias-design.md new file mode 100644 index 000000000..94ecbac49 --- /dev/null +++ b/exp/zlup/docs/future/alias-design.md @@ -0,0 +1,436 @@ +# Alias Design Notes + +> **Status:** MVP implemented (February 2026) + +This document explores adding an `alias` keyword to Zlup for creating named views +into existing data structures, particularly qubit registers. + +## Motivation + +In QEC code, it's common to partition a qubit register into logical regions: + +```zlup_nocheck +// Current approach: use slices directly +q := qalloc(9); +data := q[0..1]; // Is this a copy or a view? Unclear at a glance +x_ancilla := q[1..5]; +z_ancilla := q[5..9]; +``` + +Problems with the current approach: +1. **Intent unclear** - Is `data` a view or a copy? +2. **No overlap checking** - Nothing prevents `q[0..3]` and `q[2..5]` aliases +3. **Lifetime implicit** - Relationship to source not explicit in syntax + +## Proposed Syntax + +```zlup_nocheck +// Immutable alias (default) +alias data := q[0..1]; + +// Mutable alias (explicit) +mut alias x_ancilla := q[1..5]; + +// Multiple aliases +alias { + data := q[0..1], + x_ancilla := q[1..5], + z_ancilla := q[5..9], +} +``` + +## Semantics + +### Immutability by Default + +```zlup_nocheck +q := qalloc(4); +alias view := q[0..2]; + +h view[0]; // OK: quantum ops through immutable alias +view = q[2..4]; // ERROR: cannot reassign immutable alias +``` + +Mutable aliases can be reassigned: + +```zlup_nocheck +mut alias current := q[0..2]; +h current[0]; +current = q[2..4]; // OK: mutable alias +h current[0]; // Now operates on q[2] +``` + +### Lifetime Binding + +Aliases are bound to their source's lifetime: + +```zlup_nocheck +fn bad() -> alias []u8 { + arr := [1, 2, 3, 4]; + alias view := arr[0..2]; + return view; // ERROR: alias would outlive source +} + +fn ok(arr: []u8) -> alias []u8 { + alias view := arr[0..2]; + return view; // OK: source outlives function +} +``` + +### Overlap Checking + +**Option A: Error on overlap (strict)** +```zlup_nocheck +q := qalloc(4); +alias a := q[0..2]; +alias b := q[1..3]; // ERROR: overlaps with 'a' +``` + +**Option B: Warning on overlap (permissive)** +```zlup_nocheck +q := qalloc(4); +alias a := q[0..2]; +alias b := q[1..3]; // WARNING: overlaps with 'a', parallel ops may conflict +``` + +**Option C: Error only for mutable overlap** +```zlup_nocheck +q := qalloc(4); +alias a := q[0..2]; // immutable +alias b := q[1..3]; // immutable - OK, both read-only + +mut alias c := q[0..2]; // mutable +mut alias d := q[1..3]; // ERROR: mutable overlap with 'c' +``` + +**Recommendation:** Start with Option C - mutable overlap is an error, immutable +overlap is allowed. This matches Rust's borrowing rules conceptually. + +## Difference from Slices + +| Aspect | Slice (`x := arr[0..2]`) | Alias (`alias x := arr[0..2]`) | +|--------|--------------------------|--------------------------------| +| Intent | Ambiguous | Explicitly a view | +| Overlap check | None | Static analysis possible | +| Lifetime | Implicit | Explicit in type system | +| Reassignment | Always mutable | Immutable by default | + +An alias IS a slice underneath, but with: +1. Explicit "this is a view" semantics +2. Compiler tracking for overlap analysis +3. Immutability by default + +## AST Representation + +```rust +/// Alias binding - creates a named view into existing data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasBinding { + /// Name of the alias + pub name: String, + /// The source expression (must be slice-able) + pub source: Expr, + /// Whether this alias is mutable (can be reassigned) + pub is_mutable: bool, + /// Optional type annotation + pub ty: Option, + pub location: Option, +} + +// In Stmt enum: +pub enum Stmt { + // ... existing variants ... + Alias(AliasBinding), + AliasGroup(Vec), // For grouped aliases +} +``` + +## Semantic Analysis + +### Alias Tracking + +The semantic analyzer tracks aliases and their source relationships: + +```rust +struct AliasInfo { + name: String, + source: String, // Name of source variable + range: Option, // Static range if known + is_mutable: bool, +} + +// In SemanticAnalyzer: +aliases: BTreeMap, +``` + +### Overlap Detection + +For static ranges, detect overlaps at compile time: + +```rust +fn ranges_overlap(a: &Range, b: &Range) -> bool { + // [a.start, a.end) overlaps [b.start, b.end)? + a.start < b.end && b.start < a.end +} + +fn check_alias_overlap(&self, new_alias: &AliasInfo) -> Option { + for existing in self.aliases.values() { + if existing.source == new_alias.source { + if let (Some(r1), Some(r2)) = (&existing.range, &new_alias.range) { + if ranges_overlap(r1, r2) { + if existing.is_mutable || new_alias.is_mutable { + return Some(SemanticError::OverlappingMutableAlias { ... }); + } + } + } + } + } + None +} +``` + +### Dynamic Ranges + +For runtime-computed ranges, overlap checking happens at runtime or is skipped: + +```zlup_nocheck +fn partition(q: [n]qubit, split: usize) -> unit { + alias left := q[0..split]; // Range not known at compile time + alias right := q[split..n]; // Could overlap if split > n + // Runtime check or trust the programmer? +} +``` + +Options: +1. **Require comptime ranges** for overlap checking +2. **Insert runtime checks** for dynamic ranges +3. **Trust programmer** for dynamic ranges (document the risk) + +## Integration with Parallelism Analysis + +Aliases provide explicit partitioning information to the parallelism analyzer: + +```zlup_nocheck +q := qalloc(9); +alias { + data := q[0..1], + x_ancilla := q[1..5], + z_ancilla := q[5..9], +} + +// Analyzer knows these are disjoint, can parallelize: +h data[0]; // Independent +h x_ancilla[0]; // Independent +h z_ancilla[0]; // Independent +``` + +The `alias` block makes the partitioning explicit and verifiable. + +## Examples + +### QEC Syndrome Extraction + +```zlup_nocheck +fn surface_code_round(q: [13]qubit) -> unit { + // Explicit partitioning with aliases + alias { + data := q[0..9], + x_stabilizers := q[9..11], + z_stabilizers := q[11..13], + } + + // Operations on disjoint regions - can parallelize + prepare_data(data); + extract_x_syndrome(data, x_stabilizers); + extract_z_syndrome(data, z_stabilizers); + + // Measure stabilizers + x_syndrome := mz([2]u1) x_stabilizers; + z_syndrome := mz([2]u1) z_stabilizers; + + result("syndrome/x", x_syndrome); + result("syndrome/z", z_syndrome); +} +``` + +### Sliding Window + +```zlup_nocheck +fn sliding_window(arr: []u8, window_size: usize) -> unit { + for i in 0..(arr.len() - window_size) { + alias window := arr[i..i+window_size]; + process(window); + } +} +``` + +### Temporary Mutable View + +```zlup_nocheck +fn initialize_register(q: [8]qubit) -> unit { + // Mutable alias for initialization phase + mut alias current := q[0..4]; + initialize_block(current); + + current = q[4..8]; + initialize_block(current); +} +``` + +## Open Questions + +1. **Overlap policy:** Error, warning, or context-dependent? + +2. **Runtime checks:** For dynamic ranges, should we insert bounds checks? + +3. **Alias of alias:** Should this be allowed? + ```zlup_nocheck + alias a := q[0..4]; + alias b := a[0..2]; // Alias of alias? + ``` + +4. **Alias in function signatures:** + ```zlup_nocheck + fn process(alias data: []qubit) -> unit { ... } + // vs + fn process(data: &[]qubit) -> unit { ... } + ``` + +5. **Interaction with borrowing:** How does `alias` relate to `&` and `&mut`? + +## Minimal First Version (MVP) + +Start with a constrained version to validate the concept before adding complexity. + +### MVP Scope + +**Include:** +- `alias name := slice_expr;` - immutable only, no `mut alias` +- Static range slices only: `alias x := q[0..4];` +- Same-scope only (no passing aliases to/from functions) +- Overlap checking within same source variable +- Error on any overlap (simpler than mutable-only rule) + +**Exclude (for later):** +- `mut alias` (mutable aliases) +- `alias { }` grouped syntax +- Dynamic ranges +- Alias as function parameter/return type +- Alias of alias + +### MVP Syntax + +```zlup_nocheck +fn example() -> unit { + q := qalloc(8); + + // Simple aliases with static ranges + alias data := q[0..4]; + alias ancilla := q[4..8]; + + // Use like slices + h data[0]; + cx (data[0], ancilla[0]); + + // Overlap is an error + alias overlap := q[2..6]; // ERROR: overlaps with 'data' and 'ancilla' + + return; +} +``` + +### MVP Semantics + +1. **Immutable binding** - Cannot reassign: `data = q[0..2];` is an error +2. **View semantics** - Alias is a view, not a copy +3. **Lifetime** - Alias lives until end of scope (same as source) +4. **Static only** - Range bounds must be comptime-known for overlap checking + +### MVP Error Messages + +``` +error: overlapping alias + --> example.zlp:8:5 + | + 5 | alias data := q[0..4]; + | ---- first alias covers q[0..4] + 8 | alias overlap := q[2..6]; + | ^^^^^^^ overlaps with 'data' at indices 2..4 + | + = help: use non-overlapping ranges or access q directly +``` + +### MVP Implementation Estimate + +- Parser: ~50 lines (new `alias` statement) +- AST: ~20 lines (AliasBinding struct) +- Semantic: ~100 lines (overlap checking, symbol tracking) +- Tests: ~100 lines + +Total: ~270 lines, relatively low risk. + +### Graduation Criteria + +Expand beyond MVP when: +1. MVP is stable and tested +2. Real QEC code shows need for `mut alias` or function passing +3. User feedback indicates grouped syntax would help + +--- + +## Full Implementation Plan + +1. **Phase 1: Parser** + - Add `alias` keyword to reserved words + - Parse `alias name := expr;` and `mut alias name := expr;` + - Parse `alias { ... }` block syntax + +2. **Phase 2: AST** + - Add `AliasBinding` struct + - Add `Stmt::Alias` and `Stmt::AliasGroup` variants + +3. **Phase 3: Semantic Analysis** + - Track aliases in symbol table (new `SymbolKind::Alias`) + - Implement lifetime checking (alias can't outlive source) + - Implement overlap detection for static ranges + +4. **Phase 4: Code Generation** + - Aliases compile to slice references + - No runtime overhead for immutable aliases + +5. **Phase 5: Parallelism Analysis Integration** + - Use alias info for more precise dependency tracking + - Alias blocks provide explicit partitioning hints + +## Alternatives Considered + +### Just Use Slices +Keep current behavior, document that slices are views. +- Pro: No new syntax +- Con: Intent unclear, no overlap checking + +### Borrow Syntax (`&`) +Use Rust-style borrowing more explicitly. +- Pro: Familiar to Rust users +- Con: More complex, might not fit Zlup's simpler model + +### Named Regions in Allocator +```zlup_nocheck +q := qalloc(9) { + data: 0..1, + x_ancilla: 1..5, + z_ancilla: 5..9, +}; +``` +- Pro: Declaration and partitioning together +- Con: Only works for allocators, not general slices + +## Summary + +The `alias` keyword provides: +1. **Explicit intent** - "this is a view, not a copy" +2. **Static safety** - overlap detection for mutable aliases +3. **Immutability by default** - matches Zlup's philosophy +4. **Parallelism hints** - explicit partitioning aids analysis + +It's essentially slices with semantic meaning and compiler support for safety checks. diff --git a/exp/zlup/docs/future/build-system.md b/exp/zlup/docs/future/build-system.md new file mode 100644 index 000000000..648546ce1 --- /dev/null +++ b/exp/zlup/docs/future/build-system.md @@ -0,0 +1,337 @@ +# Build System Design + +This document outlines Zlup's build system, following Zig's philosophy: **the build system IS the language**. + +## Philosophy + +Like Zig's `build.zig`, Zlup uses `build.zlp` - a Zlup program that runs at compile time to configure the build. No separate DSL, no YAML, no TOML for build logic - just Zlup with comptime. + +**Why?** +- **One language to learn**: Build logic uses the same syntax as regular code +- **Full power of comptime**: Conditional compilation, code generation +- **Type-safe configuration**: Compiler catches config errors +- **Debuggable**: Use the same tools to debug build scripts + +## Basic Structure + +### Project Layout + +``` +my-qec-project/ +├── build.zlp # Build configuration (Zlup code) +├── zlup.toml # Simple metadata (name, version, deps) +├── src/ +│ ├── main.zlp # Entry point +│ └── lib/ +│ └── syndrome.zlp +├── tests/ +│ └── test_syndrome.zlp +└── ffi/ + └── decoder/ # Rust decoder crate + ├── Cargo.toml + └── src/lib.rs +``` + +### zlup.toml (Metadata Only) + +Simple metadata that doesn't need comptime logic: + +```toml +[package] +name = "my-qec-project" +version = "0.1.0" +authors = ["Alice "] +license = "Apache-2.0" + +[dependencies] +# External Zlup packages (future) +# other-package = "0.1.0" + +[ffi] +# Rust crates to build and link +decoder = { path = "ffi/decoder" } +``` + +### build.zlp (Build Logic) + +```zlup_nocheck +//! Build configuration for my-qec-project +//! +//! This file runs at compile time to configure the build. + +std := @import("std"); +Build := @import("build"); + +pub fn build(b: *Build) -> unit { + // Get target and optimization from CLI or defaults + target := b.standardTargetOptions(.{}); + optimize := b.standardOptimizeOption(.{}); + + // Main executable + exe := b.addExecutable(.{ + name: "qec-sim", + root_source: "src/main.zlp", + target: target, + optimize: optimize, + }); + + // Link Rust FFI library + exe.linkLibrary("decoder"); + exe.addLibraryPath("ffi/decoder/target/release"); + + // Install artifact + b.installArtifact(exe); + + // Test step + tests := b.addTest(.{ + root_source: "tests/test_syndrome.zlp", + }); + + test_step := b.step("test", "Run unit tests"); + test_step.dependOn(&tests.step); + + return; +} +``` + +## Build API + +### Build Context + +```zlup_nocheck +Build := struct { + // Target configuration + pub fn standardTargetOptions(&self, options: TargetOptions) -> Target { ... } + pub fn standardOptimizeOption(&self, options: OptimizeOptions) -> Optimize { ... } + + // Add build artifacts + pub fn addExecutable(&self, options: ExecutableOptions) -> *Executable { ... } + pub fn addLibrary(&self, options: LibraryOptions) -> *Library { ... } + pub fn addTest(&self, options: TestOptions) -> *Test { ... } + + // Build steps + pub fn step(&self, name: []const u8, description: []const u8) -> *Step { ... } + pub fn installArtifact(&self, artifact: *Artifact) -> unit { ... } + + // Options from command line + pub fn option(&self, comptime T: type, name: []const u8, description: []const u8) -> ?T { ... } +}; +``` + +### Executable Options + +```zlup_nocheck +ExecutableOptions := struct { + name: []const u8, + root_source: []const u8, + target: ?Target = none, + optimize: ?Optimize = none, + strict: bool = false, // NASA Power of 10 strict mode +}; +``` + +### Conditional Compilation + +```zlup_nocheck +pub fn build(b: *Build) -> unit { + // User-defined option + enable_noise := b.option(bool, "noise", "Enable noise modeling") orelse false; + + exe := b.addExecutable(.{ + name: "qec-sim", + root_source: "src/main.zlp", + }); + + // Conditional compilation flags + if enable_noise { + exe.addDefine("ENABLE_NOISE", "1"); + exe.linkLibrary("noise-model"); + } + + // Platform-specific + if b.target.os == .linux { + exe.linkSystemLibrary("pthread"); + } + + return; +} +``` + +### Code Generation + +```zlup_nocheck +pub fn build(b: *Build) -> unit { + // Generate lookup table at build time + table := b.addGeneratedFile("syndrome_table.zlp"); + table.generator = generate_syndrome_table; + + exe := b.addExecutable(.{ + name: "decoder", + root_source: "src/main.zlp", + }); + exe.addModule("syndrome_table", table); + + return; +} + +fn generate_syndrome_table(writer: *Writer) -> unit { + writer.print("// Auto-generated syndrome lookup table\n"); + writer.print("pub table: [256]u8 = [\n"); + + for i in 0..256 { + correction := comptime compute_correction(i); + writer.print(" {},\n", correction); + } + + writer.print("];\n"); + return; +} +``` + +## CLI Integration + +```bash +# Build using build.zlp +zlup build + +# Build with options +zlup build -Dnoise=true -Doptimize=release + +# Run tests +zlup build test + +# Run specific step +zlup build run + +# Show available steps and options +zlup build --help +``` + +## Rust FFI Integration + +The build system handles Rust crate compilation: + +```zlup_nocheck +pub fn build(b: *Build) -> unit { + // Rust decoder crate + decoder := b.addRustLibrary(.{ + name: "decoder", + path: "ffi/decoder", + profile: if b.optimize == .release { "release" } else { "debug" }, + }); + + exe := b.addExecutable(.{ + name: "qec-sim", + root_source: "src/main.zlp", + }); + + // Link the Rust library + exe.linkRustLibrary(decoder); + + return; +} +``` + +This runs `cargo build` on the Rust crate and links the resulting `.a`/`.so`. + +## Multi-Target Builds + +```zlup_nocheck +pub fn build(b: *Build) -> unit { + targets := [_]Target{ + .{ .os = .linux, .arch = .x86_64 }, + .{ .os = .macos, .arch = .aarch64 }, + .{ .os = .windows, .arch = .x86_64 }, + }; + + for target in targets { + exe := b.addExecutable(.{ + name: f"qec-sim-{target.os}-{target.arch}", + root_source: "src/main.zlp", + target: target, + }); + b.installArtifact(exe); + } + + return; +} +``` + +## Comparison with Alternatives + +| Approach | Pros | Cons | +|----------|------|------| +| **build.zlp (Zlup)** | Full language power, type-safe, debuggable | Need Zlup knowledge | +| build.zig (Zig) | Proven approach, powerful | Different language | +| Cargo.toml (Rust) | Simple, declarative | Limited logic | +| CMake | Cross-platform | Complex DSL | +| Make | Universal | Arcane syntax | + +## Implementation Plan + +1. **Phase 1**: Basic build.zlp parsing and execution +2. **Phase 2**: Executable and library targets +3. **Phase 3**: Test integration +4. **Phase 4**: Rust FFI library linking +5. **Phase 5**: Code generation support +6. **Phase 6**: Multi-target builds + +## Example: Complete QEC Project + +```zlup_nocheck +//! build.zlp for a surface code simulator + +std := @import("std"); +Build := @import("build"); + +pub fn build(b: *Build) -> unit { + target := b.standardTargetOptions(.{}); + optimize := b.standardOptimizeOption(.{}); + + // Options + distance := b.option(u32, "distance", "Code distance") orelse 3; + strict := b.option(bool, "strict", "NASA Power of 10 strict mode") orelse true; + + // Rust MWPM decoder + mwpm := b.addRustLibrary(.{ + name: "mwpm", + path: "ffi/mwpm-decoder", + }); + + // Main simulator + sim := b.addExecutable(.{ + name: "surface-sim", + root_source: "src/main.zlp", + target: target, + optimize: optimize, + strict: strict, + }); + sim.addDefine("CODE_DISTANCE", std.fmt.comptimePrint("{}", distance)); + sim.linkRustLibrary(mwpm); + + b.installArtifact(sim); + + // Tests + tests := b.addTest(.{ + root_source: "tests/all.zlp", + strict: strict, + }); + tests.linkRustLibrary(mwpm); + + test_step := b.step("test", "Run all tests"); + test_step.dependOn(&tests.step); + + // Benchmark step + bench := b.addExecutable(.{ + name: "bench", + root_source: "bench/main.zlp", + optimize: .release, + }); + bench.linkRustLibrary(mwpm); + + bench_step := b.step("bench", "Run benchmarks"); + bench_step.dependOn(&bench.run()); + + return; +} +``` diff --git a/exp/zlup/docs/future/custom-gates-design.md b/exp/zlup/docs/future/custom-gates-design.md new file mode 100644 index 000000000..ede1b6c84 --- /dev/null +++ b/exp/zlup/docs/future/custom-gates-design.md @@ -0,0 +1,1493 @@ +# Custom Gate Design Notes + +> **Status:** Design exploration (February 2026) + +## Summary + +This document defines Zlup's gate system. The core design decisions are: + +### All Quantum Operations Are Target-Provided + +Zlup has **no built-in quantum operations**. Everything that touches qubits - gates, +preparation, measurement - is declared and provided by the compilation target. + +- `std.gates` declares the "standard" set (`h`, `cx`, `pz`, `mz`, etc.) +- Targets implement what they support +- Compile error if you use an unsupported operation +- **No fallbacks, no implicit decomposition** + +### Two Types of Gates + +| Type | Syntax | Provider | Use Case | +|------|--------|----------|----------| +| **Target** | `declare gate name(...)` | Hardware/simulator/noise model | Native operations | +| **Composite** | `gate name(...) { body }` | Zlup code | Abstractions, subroutines | + +### Only Two Built-ins + +| Built-in | Purpose | +|----------|---------| +| `qalloc` | Resource allocation (doesn't touch quantum state) | +| `result` | Output emission (classical) | + +Everything else requires `use std.gates.*` or custom declarations. + +### Composite Gates Are Full Subroutines + +Composites can include preparation, measurement, classical logic, control flow, +and return values: + +```zlup_nocheck +gate measure_reset(q: qubit) -> u1 { + r := mz(u1) q; + if r == 1 { x q; } + return r; +} +``` + +### Compile-Time Target Validation + +```bash +zlup compile program.zlp --target trapped_ion +``` + +The compiler validates all gate usage against the target's gate set. Composites +are validated recursively. + +### IDE Support via Project Config + +```toml +# zlup.toml +[target] +default = "trapped_ion" +``` + +IDE reads config and validates accordingly. `@import("target")` gives access to +current target's definitions. + +--- + +## Table of Contents + +- [Core Principle: All Gates Are Target-Provided](#core-principle-all-gates-are-target-provided) +- [Two Types of Gates](#two-types-of-gates) +- [Why This Design](#why-this-design) +- [Proposed Syntax](#proposed-syntax) +- [Noise Model Integration](#noise-model-integration) +- [Composite Gate Generalization](#composite-gate-generalization) +- [Compilation Model](#compilation-model) +- [IDE / Language Server Support](#ide--language-server-support) +- [Open Questions](#open-questions) + +--- + +## Core Principle: All Gates Are Target-Provided + +Zlup has **no built-in gates**. Every gate - including "standard" gates like `h`, `cx`, +`rz` - is declared and must be provided by the compilation target. + +The standard library (`std.gates`) declares the common gate set that most targets support. +But these are not special - they use the same `declare gate` mechanism as any other gate. + +```zlup_nocheck +// In std/gates.zlp - the "standard" operation set +// These are declarations, not implementations + +// Preparation (reset) +pub declare gate pz(q: qubit); // Prepare in |0⟩ (Z basis) +pub declare gate px(q: qubit); // Prepare in |+⟩ (X basis) +pub declare gate py(q: qubit); // Prepare in |+i⟩ (Y basis) + +// Measurement +pub declare gate mz(q: qubit) -> u1; // Measure in Z basis +pub declare gate mx(q: qubit) -> u1; // Measure in X basis +pub declare gate my(q: qubit) -> u1; // Measure in Y basis + +// Single-qubit gates +pub declare gate h(q: qubit); +pub declare gate x(q: qubit); +pub declare gate y(q: qubit); +pub declare gate z(q: qubit); +pub declare gate t(q: qubit); +pub declare gate tdg(q: qubit); +pub declare gate sx(q: qubit); +pub declare gate sy(q: qubit); +pub declare gate sz(q: qubit); + +// Parameterized single-qubit +pub declare gate rx(theta: a64)(q: qubit); +pub declare gate ry(theta: a64)(q: qubit); +pub declare gate rz(theta: a64)(q: qubit); + +// Two-qubit gates +pub declare gate cx(ctrl: qubit, tgt: qubit); +pub declare gate cy(ctrl: qubit, tgt: qubit); +pub declare gate cz(ctrl: qubit, tgt: qubit); +pub declare gate swap(a: qubit, b: qubit); + +// Parameterized two-qubit +pub declare gate rzz(theta: a64)(a: qubit, b: qubit); +pub declare gate crz(theta: a64)(ctrl: qubit, tgt: qubit); + +// Three-qubit +pub declare gate ccx(a: qubit, b: qubit, c: qubit); +``` + +**Targets must implement these operations.** A target that doesn't support `h` or +`pz` will fail at compile time when code uses them. + +## Two Types of Gates + +### Target Gates (Declared) + +Only the signature is declared. The target provides the implementation. + +**Who provides implementations:** +- **Hardware**: Native gates the device supports +- **Simulator**: Matrix implementations, optimized algorithms +- **Noise model**: Gates with specific error characteristics + +```zlup_nocheck +// User declares additional target gates beyond std.gates +declare gate ms(theta: a64)(a: qubit, b: qubit); +declare gate sqrt_iswap(a: qubit, b: qubit); +``` + +**Key property:** No Zlup-level implementation exists. If target doesn't support +the gate, compilation fails. No fallbacks, no hidden decomposition. + +### Composite Gates (Defined) + +Defined in Zlup as sequences of target gates (from `std.gates` or custom declarations) +or other composite gates. + +```zlup_nocheck +// Uses target gates from std.gates (h, cx, rz) +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx(ctrl, tgt); + rz(theta) tgt; + cx(ctrl, tgt); + h tgt; +} + +// Uses custom target gate (ms) - only works on targets that support ms +gate ion_entangle(a: qubit, b: qubit) { + ms(1/4 turns) (a, b); +} + +// Uses another composite gate +gate double_rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + rzx(theta) (ctrl, tgt); + rzx(theta) (ctrl, tgt); +} +``` + +**Key property:** Zlup knows the decomposition. Composite gates are portable across +any target that supports their constituent target gates. A composite using only +`std.gates` works on any standard-compliant target. A composite using `ms` only +works on targets that provide `ms`. + +## Why This Design? + +### Explicit Over Implicit (Zig-style, No Prelude) + +Following Zig, Zlup has **no prelude**. Nothing is automatically imported. + +**Built into the language (always available):** +- Primitive types: `u8`, `i32`, `bool`, `f64`, `a64`, `qubit`, `unit`, etc. +- Keywords: `if`, `for`, `fn`, `gate`, `declare`, `pub`, etc. +- Built-in functions: `@import`, `@size_of`, `@type_info`, etc. +- Resource management: `qalloc` (allocation, not a quantum operation) +- Output: `result` (classical emission, not a quantum operation) + +**Requires explicit import (target-provided):** +- All quantum operations: `h`, `cx`, `rz`, `pz`, `mz`, etc. +- Library functions: `std.math`, `std.bits`, etc. + +```zlup_nocheck +std := @import("std"); +use std.gates.*; // h, cx, rz, pz, mz, ... + +pub fn main() -> unit { + q := qalloc(2); // built-in (resource allocation) + pz q; // imported (target-provided) + h q[0]; // imported (target-provided) + cx (q[0], q[1]); // imported (target-provided) + r := mz(u1) q[0]; // imported (target-provided) + result("out", r); // built-in (output) + return; +} +``` + +The distinction: `qalloc` and `result` are resource/IO operations that don't touch +quantum state. Everything that interacts with qubits (gates, prep, measurement) is +target-provided because targets implement these differently and noise models need +to reason about them. + +This means you can always answer "where did this come from?" by looking at imports. + +### Fail Fast + +If a target doesn't support a gate you use, compilation fails immediately with a +clear error message. No silent fallbacks or hidden decompositions that might +introduce unexpected behavior or performance characteristics. + +### Target Flexibility + +Different targets have different native gate sets: +- Trapped ion: `ms`, `gpi`, `gpi2` +- Superconducting: `sqrt_iswap`, `sycamore` +- Photonic: `beamsplitter`, `phase_shift` + +Rather than trying to support all of these as "built-ins", each target declares +what it supports. Users declare additional gates as needed. + +### Noise Model Control + +Noise models are targets too. A noise model might: +- Provide standard gates with calibrated error rates +- Provide synthetic gates for testing (`perfect_cx`, `very_noisy_cx`) +- Reject gates it doesn't have noise data for + +## Proposed Syntax + +### Importing Standard Gates + +All quantum operations (including `pz` and `mz`) must be imported before use: + +```zlup_nocheck +// Import all standard operations +std := @import("std"); +use std.gates.*; // pz, mz, h, x, cx, rz, etc. + +pub fn main() -> unit { + q := qalloc(2); + pz q; // from std.gates + h q[0]; // from std.gates + cx (q[0], q[1]); // from std.gates + r := mz(u1) q[0]; // from std.gates + result("out", r); + return; +} +``` + +Or import selectively: + +```zlup_nocheck +std := @import("std"); +use std.gates.{pz, mz, h, cx}; // only import what you need + +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + r := mz(u1) q[0]; + result("out", r); + // x q[0]; // Error: 'x' not imported + return; +} +``` + +Or use qualified names: + +```zlup_nocheck +std := @import("std"); + +pub fn main() -> unit { + q := qalloc(2); + std.gates.pz q; + std.gates.h q[0]; + std.gates.cx (q[0], q[1]); + r := std.gates.mz(u1) q[0]; + result("out", r); + return; +} +``` + +### Composite Gates + +```zlup_nocheck +// Basic composite gate with angle parameter +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +// Fixed gate (no parameters) +gate echo(q: qubit) { + x q; + x q; +} + +// Multi-qubit gate +gate toffoli(a: qubit, b: qubit, c: qubit) { + h c; + cx (b, c); tdg c; + cx (a, c); t c; + cx (b, c); tdg c; + cx (a, c); + t b; t c; h c; + cx (a, b); + t a; tdg b; + cx (a, b); +} + +// Public gate (exported from module) +pub gate logical_h(data: [9]qubit) { + inline for i in 0..9 { + h data[i]; + } +} +``` + +### Target Gates (Declared) + +```zlup_nocheck +// Declare a gate the target must provide +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// No angle parameters +declare gate sqrt_swap(a: qubit, b: qubit); + +// Three-qubit native gate +declare gate native_toffoli(a: qubit, b: qubit, c: qubit); + +// With target hint (documentation/validation) +@target("trapped_ion") +declare gate ms(theta: a64)(a: qubit, b: qubit); + +@target("simulator:pecos") +declare gate optimized_toffoli(a: qubit, b: qubit, c: qubit); + +@target("noise_model:depolarizing") +declare gate noisy_cx(ctrl: qubit, tgt: qubit); +``` + +### Usage + +```zlup_nocheck +std := @import("std"); +use std.gates.*; // pz, mz, h, cx, rz, ... + +// Declare additional target gate +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// Define composite gate (uses std.gates) +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +gate echo(q: qubit) { + x q; + x q; +} + +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Use standard gate + h q[0]; + + // Use composite gate + rzx(1/4 turns) (q[0], q[1]); + + // Use target gate (target must support) + ms(1/8 turns) (q[2], q[3]); + + // Batch syntax works too + echo {q[0], q[1], q[2]}; + + // Measure + r := mz(u1) q[0]; + result("out", r); + + return; +} +``` + +## Noise Model Integration + +The key question: how should noise models treat composite gates? + +### Option A: Atomic by Default + +Composite gates are treated as single units for noise purposes. The noise model +sees "one rzx gate" not "h, cx, rz, cx, h". + +```zlup_nocheck +// Default: noise model treats as atomic +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +// Override: apply noise to each constituent +@decomposed +gate debug_rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + // Same body, but noise applied per-gate +} +``` + +**Rationale:** Most custom gates are defined precisely because users want to +reason about them as units. Debugging/analysis can use `@decomposed`. + +### Option B: Decomposed by Default + +Noise applied to each constituent gate. Mark atomic explicitly. + +```zlup_nocheck +// Default: noise per constituent +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { ... } + +// Override: treat as single unit +@atomic +gate atomic_rzx(theta: a64)(ctrl: qubit, tgt: qubit) { ... } +``` + +**Rationale:** More conservative; explicit atomicity prevents surprises. + +### Option C: Noise Model Decides + +Gate definition doesn't specify; noise model configuration lists atomic gates. + +```json +// noise_config.json +{ + "atomic_gates": ["rzx", "logical_h", "echo"], + "decomposed_gates": ["debug_toffoli"] +} +``` + +**Rationale:** Same gate can be atomic or decomposed depending on simulation needs. + +### Recommendation + +**Option A (atomic by default)** with **Option C (noise model override)**. + +- Gates defined with `gate` keyword are atomic by default +- `@decomposed` attribute forces per-constituent noise +- Noise model config can override either direction + +```zlup_nocheck +// Atomic by default +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { ... } + +// Force decomposed (ignores noise model config) +@decomposed +gate always_decomposed(q: qubit) { ... } + +// Force atomic (ignores noise model config) +@atomic +gate always_atomic(q: qubit) { ... } +``` + +## Inlining Behavior + +Separate from noise, inlining affects optimization: + +```zlup_nocheck +// Default: compiler decides based on size/usage +gate small_gate(q: qubit) { x q; } + +// Force inline (always expand at call site) +@inline +gate must_inline(q: qubit) { ... } + +// Prevent inline (preserve structure in output) +@noinline +gate preserve_structure(q: qubit) { ... } +``` + +**Interaction with noise:** +- `@inline` + atomic noise = inline then apply atomic noise +- `@noinline` + decomposed noise = keep structure, noise per constituent + +## Arity and Type Checking + +The compiler tracks gate signatures for type checking: + +```zlup_nocheck +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { ... } +// ^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// 1 angle 2 qubits + +// Valid +rzx(1/4 turns) (q[0], q[1]); + +// Error: wrong number of angle parameters +rzx (q[0], q[1]); // Missing angle + +// Error: wrong number of qubits +rzx(1/4 turns) (q[0]); // Needs 2 qubits + +// Error: type mismatch +rzx(42) (q[0], q[1]); // 42 is not an angle +``` + +### Target Gate Validation + +For target gates, validation happens at codegen time: + +```zlup_nocheck +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// At compile time: type-checked against declaration +ms(1/4 turns) (q[0], q[1]); // OK + +// At codegen time: check if target supports "ms" +// Error if target doesn't have "ms" with matching signature +``` + +**Target capabilities:** + +Different targets support different gates: + +| Target | Example Supported Gates | +|--------|------------------------| +| Hardware (trapped ion) | `ms`, `gpi`, `gpi2` | +| Hardware (superconducting) | `sqrt_iswap`, `sycamore` | +| Simulator (statevector) | Any gate with provided matrix | +| Noise model | Gates with calibrated noise parameters | + +The `@target` hint helps catch mismatches early: + +```zlup_nocheck +@target("trapped_ion") +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// Compiling with --target superconducting: +// Warning: gate 'ms' declared for 'trapped_ion' but targeting 'superconducting' +``` + +## AST Representation + +```rust +/// Custom gate definition (composite) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateDecl { + /// Gate name + pub name: String, + /// Angle/value parameters (e.g., theta: a64) + pub params: Vec, + /// Qubit parameters with names + pub qubits: Vec, + /// Gate body (sequence of statements) + pub body: Block, + /// Whether this gate is public + pub is_pub: bool, + /// Noise behavior + pub noise_mode: NoiseMode, + /// Inlining behavior + pub inline_mode: InlineMode, + pub location: Option, +} + +/// Target gate (provided by compilation target) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TargetGate { + /// Gate name (must match target's implementation) + pub name: String, + /// Angle/value parameters + pub params: Vec, + /// Qubit count and names + pub qubits: Vec, + /// Optional target hint (hardware, simulator, noise_model) + pub target_hint: Option, + pub location: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateParam { + pub name: String, + pub ty: Type, // Usually Type::Angle (a64) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QubitParam { + pub name: String, + // Future: could support qubit arrays [N]qubit +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub enum NoiseMode { + #[default] + Atomic, // Treat as single unit for noise + Decomposed, // Apply noise to each constituent +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub enum InlineMode { + #[default] + Auto, // Compiler decides + Always, // @inline + Never, // @noinline +} +``` + +## SLR Output + +Composite gates can be output in two ways: + +### Expanded (Default) + +Gate body is inlined at each call site: + +```json +{ + "type": "GateOp", + "kind": "H", + "targets": [{"allocator": "q", "index": 1}] +}, +{ + "type": "GateOp", + "kind": "CX", + "targets": [ + {"allocator": "q", "index": 0}, + {"allocator": "q", "index": 1} + ] +} +// ... etc +``` + +### Preserved Structure + +When `@noinline` or for backends that support custom gates: + +```json +{ + "type": "CustomGateOp", + "name": "rzx", + "params": [{"type": "angle", "value": 0.25, "unit": "turns"}], + "targets": [ + {"allocator": "q", "index": 0}, + {"allocator": "q", "index": 1} + ], + "atomic_noise": true +} +``` + +### Target Gates + +Always output as target gate ops (no body to expand): + +```json +{ + "type": "TargetGateOp", + "name": "ms", + "params": [{"type": "angle", "value": 0.125, "unit": "turns"}], + "targets": [ + {"allocator": "q", "index": 0}, + {"allocator": "q", "index": 1} + ], + "target_hint": "trapped_ion" +} +``` + +The target (hardware, simulator, or noise model) interprets this gate according to +its own implementation. + +## Examples + +### QEC: Logical Gates + +```zlup_nocheck +std := @import("std"); +use std.gates.{h, cx, pz}; + +// Distance-3 surface code logical Hadamard +pub gate logical_h(data: [9]qubit) { + // Transversal H + inline for i in 0..9 { + h data[i]; + } +} + +// Logical CNOT between two codes +pub gate logical_cx(ctrl: [9]qubit, tgt: [9]qubit) { + inline for i in 0..9 { + cx (ctrl[i], tgt[i]); + } +} + +pub fn main() -> unit { + mut base := qalloc(18); + code1 := base.child(9); + code2 := base.child(9); + + pz code1; + pz code2; + + logical_h(code1); + logical_cx(code1, code2); + + return; +} +``` + +### Target: Hardware (Trapped Ion) + +```zlup_nocheck +std := @import("std"); +use std.gates.pz; + +// Mølmer-Sørensen gate (trapped ion native) +@target("trapped_ion") +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// Global rotation (all ions) +@target("trapped_ion") +declare gate gpi(theta: a64)(q: qubit); + +pub fn ion_bell_state() -> unit { + q := qalloc(2); + pz q; + + // Native trapped-ion Bell state preparation + ms(1/4 turns) (q[0], q[1]); + + return; +} +``` + +### Target: Simulator + +```zlup_nocheck +std := @import("std"); +use std.gates.pz; + +// Simulator provides optimized implementation +@target("simulator") +declare gate optimized_toffoli(a: qubit, b: qubit, c: qubit); + +// Simulator-specific controlled rotation (avoids decomposition) +@target("simulator:pecos") +declare gate crx(theta: a64)(ctrl: qubit, tgt: qubit); + +pub fn use_simulator_gates() -> unit { + q := qalloc(3); + pz q; + + // Simulator applies this directly (no decomposition overhead) + optimized_toffoli(q[0], q[1], q[2]); + + return; +} +``` + +### Target: Noise Model + +```zlup_nocheck +std := @import("std"); +use std.gates.pz; + +// Noise model provides gate with calibrated error characteristics +@target("noise_model:device_xyz") +declare gate calibrated_cx(ctrl: qubit, tgt: qubit); + +// Noise model might define entirely synthetic gates for testing +@target("noise_model:test") +declare gate perfect_cx(ctrl: qubit, tgt: qubit); // No noise +@target("noise_model:test") +declare gate very_noisy_cx(ctrl: qubit, tgt: qubit); // High error rate + +pub fn noise_comparison() -> unit { + q := qalloc(4); + pz q; + + // Compare different noise characteristics + calibrated_cx(q[0], q[1]); // Realistic noise from calibration data + perfect_cx(q[2], q[3]); // Idealized for debugging + + return; +} +``` + +### Dynamical Decoupling + +```zlup_nocheck +std := @import("std"); +use std.gates.{h, x, y, pz}; + +// Echo sequence for noise suppression +@atomic // Noise model sees this as one "echo" operation +gate echo_xy(q: qubit) { + x q; + y q; + x q; + y q; +} + +// CPMG sequence +@atomic +gate cpmg(n: comptime usize)(q: qubit) { + inline for _ in 0..n { + x q; + x q; + } +} + +pub fn protected_operation() -> unit { + q := qalloc(1); + pz q; + + h q[0]; + echo_xy q[0]; // Protect during idle time + h q[0]; + + return; +} +``` + +### Debugging with Decomposed Noise + +```zlup_nocheck +std := @import("std"); +use std.gates.{h, cx, rz}; + +// Atomic noise (default) +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +// Same gate, but see noise on each component +@decomposed +gate debug_rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +// Compare noise behavior +pub fn compare_noise() -> unit { + q := qalloc(4); + pz q; + + // Atomic noise + rzx(1/4 turns) (q[0], q[1]); + + // Per-gate noise (for analysis) + debug_rzx(1/4 turns) (q[2], q[3]); + + return; +} +``` + +## Composite Gate Generalization + +Composite gates need to be more than just sequences of other gates. Real quantum +operations often include preparation, measurement, classical logic, and feedforward. + +### What Composites Need to Support + +```zlup_nocheck +std := @import("std"); +use std.gates.*; + +// Measure-and-reset: returns measurement, leaves qubit in |0⟩ +gate measure_reset(q: qubit) -> u1 { + r := mz(u1) q; + if r == 1 { + x q; // flip back to |0⟩ + } + return r; +} + +// Teleportation protocol +gate teleport(psi: qubit, epr0: qubit, epr1: qubit) -> (u1, u1) { + // Bell measurement + cx (psi, epr0); + h psi; + m1 := mz(u1) psi; + m2 := mz(u1) epr0; + + // Feedforward corrections + if m2 == 1 { x epr1; } + if m1 == 1 { z epr1; } + + return (m1, m2); +} + +// Repeat-until-success T gate +gate rus_t(q: qubit, ancilla: qubit) -> bool { + for attempt in 0..10 { // bounded attempts + pz ancilla; + h ancilla; + t ancilla; + cx (q, ancilla); + h ancilla; + + r := mz(u1) ancilla; + if r == 0 { + return true; // success + } + // Undo partial operation and retry + sz q; // correction + } + return false; // failed after max attempts +} + +// Syndrome extraction round +gate extract_syndrome(data: [4]qubit, ancilla: qubit) -> u1 { + pz ancilla; + h ancilla; + + inline for i in 0..4 { + cx (ancilla, data[i]); + } + + h ancilla; + return mz(u1) ancilla; +} +``` + +### What This Means + +Composite gates can include: + +| Feature | Example | Purpose | +|---------|---------|---------| +| Target gates | `h q; cx (a, b);` | Core operations | +| Preparation | `pz ancilla;` | Initialize qubits mid-circuit | +| Measurement | `r := mz(u1) q;` | Extract classical information | +| Return values | `-> u1`, `-> (u1, u1)` | Return measurement results | +| Classical variables | `r := mz(...); count += 1;` | Track state | +| Control flow | `if r == 1 { x q; }` | Feedforward corrections | +| Bounded loops | `for i in 0..n { ... }` | Repeated operations | +| Other composites | `measure_reset(q);` | Composition | + +### Gate vs Function: What's the Difference? + +If gates can do all this, how are they different from functions? + +| Aspect | `gate` | `fn` | +|--------|--------|------| +| **Semantics** | "This is a quantum operation" | General code | +| **Noise model** | Treated as unit (by default) | No special treatment | +| **Target override** | Target can provide native impl | No target override | +| **Inlining** | Controlled by `@atomic`/`@decomposed` | Normal inlining rules | +| **Scheduling** | May have timing implications | No timing semantics | + +The key distinction: a `gate` declaration says "this is a quantum operation that +targets and noise models should reason about". A function is just code. + +```zlup_nocheck +// Gate: noise model can treat as atomic "teleport" operation +gate teleport(psi: qubit, epr0: qubit, epr1: qubit) -> (u1, u1) { ... } + +// Function: just code, no special quantum semantics +fn run_teleportation_experiment(shots: u32) -> unit { ... } +``` + +### Noise Model Implications + +When a composite gate includes measurement and feedforward: + +**Atomic mode (`@atomic` or default):** +- Noise model sees one "teleport" operation +- Applies noise characteristic of the whole operation +- Internal structure hidden from noise model + +**Decomposed mode (`@decomposed`):** +- Noise model sees each constituent operation +- `cx`, `h`, `mz`, conditional `x`, conditional `z` each get noise +- Full visibility into structure + +```zlup_nocheck +// Atomic: noise model sees "measure_reset" as one operation +gate measure_reset(q: qubit) -> u1 { ... } + +// Decomposed: noise model sees mz + conditional x +@decomposed +gate debug_measure_reset(q: qubit) -> u1 { ... } +``` + +## Compilation Model + +When compiling Zlup code, you specify a target. The target declares what gates it +supports. The compiler validates your code against the target's gate set. + +### Target Gate Sets + +Each target provides a gate set definition: + +``` +# trapped_ion.target +gates: + - pz, px + - mz, mx + - gpi, gpi2 + - ms + - rz + +# superconducting.target +gates: + - pz + - mz + - h, x, y, z + - rx, ry, rz + - cx, cz + - sqrt_iswap + +# simulator_pecos.target +gates: + - [all of std.gates] + - optimized_toffoli + - any_unitary # simulator can do arbitrary unitaries +``` + +### Compile-Time Validation + +```bash +# Compile for trapped ion target +zlup compile program.zlp --target trapped_ion +``` + +The compiler: +1. Loads target's gate set +2. Checks every gate usage in your code +3. Errors if you use a gate the target doesn't support + +```zlup_nocheck +std := @import("std"); +use std.gates.*; + +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; // Error on trapped_ion: 'h' not supported + cx (q[0], q[1]); // Error on trapped_ion: 'cx' not supported + return; +} +``` + +``` +error: gate 'h' not supported by target 'trapped_ion' + --> program.zlp:7:5 + | + 7 | h q[0]; + | ^ target 'trapped_ion' does not provide 'h' + | + = help: trapped_ion supports: pz, px, mz, mx, gpi, gpi2, ms, rz + = help: consider decomposing 'h' using supported gates +``` + +### Composites Are Validated Recursively + +When you define a composite gate, the compiler checks that all gates it uses are +supported by the target: + +```zlup_nocheck +// This composite uses h and cx +gate bell(a: qubit, b: qubit) { + h a; + cx (a, b); +} + +pub fn main() -> unit { + q := qalloc(2); + pz q; + bell(q[0], q[1]); // Error: bell uses 'h' and 'cx', not supported + return; +} +``` + +The error traces through: +``` +error: gate 'h' not supported by target 'trapped_ion' + --> program.zlp:4:5 + | + 4 | h a; + | ^ in composite gate 'bell' + | + --> program.zlp:11:5 + | +11 | bell(q[0], q[1]); + | ^^^^ called here +``` + +### Writing Portable Code + +To write code that works on multiple targets: + +**Option 1: Use only common gates** +```zlup_nocheck +// Only use gates that all your targets support +// This limits expressiveness but maximizes portability +``` + +**Option 2: Target-specific modules** +```zlup_nocheck +// trapped_ion/bell.zlp +gate bell(a: qubit, b: qubit) { + ms(1/4 turns) (a, b); + // ... ion-native implementation +} + +// superconducting/bell.zlp +gate bell(a: qubit, b: qubit) { + h a; + cx (a, b); +} + +// main.zlp - import based on target +bell := @import(@target_name() ++ "/bell.zlp"); +``` + +**Option 3: Comptime target checking** +```zlup_nocheck +gate bell(a: qubit, b: qubit) { + if (comptime @target_has("ms")) { + ms(1/4 turns) (a, b); + } else if (comptime @target_has("cx")) { + h a; + cx (a, b); + } else { + @compile_error("No known bell implementation for this target"); + } +} +``` + +### No Implicit Fallbacks + +The compiler never silently substitutes one gate for another. If you write `h q[0]` +and the target doesn't support `h`, you get a compile error. Period. + +This is intentional: +- **Explicit**: You know exactly what gates your code uses +- **Predictable**: No surprise decompositions affecting performance/noise +- **Auditable**: Code review can verify target compatibility + +## IDE / Language Server Support + +The IDE needs to know which target you're developing for to provide proper +errors, autocomplete, and diagnostics. Several approaches: + +### Option A: Project Configuration + +Like Rust's `Cargo.toml`, a project config specifies the default target: + +```toml +# zlup.toml +[project] +name = "my_qec_code" + +[target] +default = "trapped_ion" + +# Optional: define multiple targets for the project +[target.trapped_ion] +definition = "targets/trapped_ion.toml" + +[target.simulator] +definition = "targets/pecos_simulator.toml" +``` + +The IDE reads `zlup.toml` and uses the default target for validation. This is +similar to how rust-analyzer reads `Cargo.toml`. + +### Option B: Import Target Gate Set + +Make the target's gate set explicitly importable: + +```zlup_nocheck +// Import current target's definitions (set by zlup.toml or CLI) +target := @import("target"); +use target.gates.*; // IDE knows exactly which gates are available + +pub fn main() -> unit { + q := qalloc(2); + pz q; // IDE: ✓ available in target + h q[0]; // IDE: ✗ not available in trapped_ion (red squiggle) + return; +} +``` + +Or import a specific target explicitly (useful during development): + +```zlup_nocheck +// Explicitly develop against trapped_ion +ion := @import("targets/trapped_ion"); +use ion.gates.*; + +pub fn main() -> unit { + q := qalloc(2); + pz q; + ms(1/4 turns) (q[0], q[1]); // IDE knows ms is available + return; +} +``` + +### Option C: Builtin Target Info (Zig-style) + +Like Zig's `@import("builtin")`: + +```zlup_nocheck +builtin := @import("builtin"); + +// Access target info at comptime +const target_name = builtin.target.name; // "trapped_ion" +const has_cx = builtin.target.has_gate("cx"); // false + +// Conditional compilation +if (comptime builtin.target.has_gate("cx")) { + cx (q[0], q[1]); +} else { + // Alternative implementation +} +``` + +### Option D: Per-File Target Annotation + +For files that are target-specific: + +```zlup_nocheck +//! target: trapped_ion +//! This module contains trapped-ion specific implementations + +std := @import("std"); +use std.gates.*; + +// IDE knows to validate against trapped_ion +pub gate ion_bell(a: qubit, b: qubit) { + ms(1/4 turns) (a, b); +} +``` + +### Recommended Approach + +Combine several mechanisms: + +1. **Project config (`zlup.toml`)**: Default target for the project, IDE reads this +2. **Explicit target import**: `target := @import("target")` makes dependencies clear +3. **Per-file annotations**: Override for target-specific files +4. **Builtin info**: `@import("builtin").target` for comptime decisions + +```zlup_nocheck +// Most files: use project default via @import("target") +target := @import("target"); +use target.gates.*; + +// Target-specific file: annotate at top +//! target: trapped_ion +ion := @import("targets/trapped_ion"); +use ion.gates.*; + +// Portable code: check at comptime +builtin := @import("builtin"); +if (comptime builtin.target.has_gate("ms")) { + // Use native MS gate +} else { + // Use decomposition +} +``` + +### How Zig and Rust Handle This + +**Zig:** +```zig +const builtin = @import("builtin"); +const native_arch = builtin.cpu.arch; + +if (native_arch == .x86_64) { + // x86-specific code +} +``` +- Build system (`build.zig`) specifies target +- IDE reads build.zig +- `@import("builtin")` gives target info in code + +**Rust:** +```rust +#[cfg(target_arch = "x86_64")] +fn optimized_impl() { ... } + +#[cfg(not(target_arch = "x86_64"))] +fn optimized_impl() { ... } +``` +- `Cargo.toml` + `.cargo/config.toml` specify target +- rust-analyzer reads config +- `cfg` attributes for conditional compilation +- `cfg!` macro for runtime checks (but resolved at compile time) + +### Target Definition Files + +Targets are defined in TOML files: + +```toml +# targets/trapped_ion.toml +[target] +name = "trapped_ion" +description = "Generic trapped ion quantum computer" + +[gates] +# Preparation and measurement +prep = ["pz", "px"] +meas = ["mz", "mx"] + +# Native single-qubit +single = ["gpi", "gpi2", "rz"] + +# Native two-qubit +two = ["ms"] + +# Not natively supported (would need decomposition) +# h, x, y, z, cx, cy, cz - not listed, so not available +``` + +```toml +# targets/pecos_simulator.toml +[target] +name = "pecos_simulator" +description = "PECOS statevector simulator" + +[gates] +# Simulator supports everything in std.gates +include = ["std.gates.*"] + +# Plus some simulator-specific operations +extra = ["any_unitary", "state_snapshot", "density_matrix"] +``` + +The IDE loads the target definition and validates accordingly. + +## Open Questions + +1. **Qubit array parameters:** Should gates support `[N]qubit` parameters? + ```zlup_nocheck + gate transversal_h(data: [N]qubit) { ... } // Generic over N? + gate logical_h(data: [9]qubit) { ... } // Fixed size? + ``` + +2. **Classical parameters:** Should gates support non-angle parameters? + ```zlup_nocheck + gate conditional_x(condition: bool)(q: qubit) { + if condition { x q; } + } + ``` + +3. **Return values:** Should gates return measurement results? + ```zlup_nocheck + gate measure_reset(q: qubit) -> u1 { + result := mz(u1) q; + pz q; + return result; + } + ``` + Or should this remain a function, not a gate? + +4. **Gate composition:** Should gates call other custom gates? + ```zlup_nocheck + gate double_echo(q: qubit) { + echo q; + echo q; + } + ``` + +5. **Verification:** Should we allow assertions about gate behavior? + ```zlup_nocheck + @unitary_check // Verify at comptime that this is unitary + gate my_gate(q: qubit) { ... } + ``` + +6. **Target capability checking:** Compile error at codegen time if target doesn't + support the gate. No fallbacks - explicit failure is the only option. + +7. **Multiple target hints:** What if code uses gates from multiple targets? + ```zlup_nocheck + @target("trapped_ion") + declare gate ms(theta: a64)(a: qubit, b: qubit); + + @target("superconducting") + declare gate sqrt_iswap(a: qubit, b: qubit); + + // Compile error if targeting trapped_ion and using sqrt_iswap + // Clear message: "gate 'sqrt_iswap' declared for 'superconducting' + // but compiling for 'trapped_ion'" + ``` + +8. **Composite portability:** Should composites be allowed to use target-specific gates? + ```zlup_nocheck + // This composite only works on trapped_ion targets + gate ion_bell(a: qubit, b: qubit) { + ms(1/4 turns) (a, b); // ms is trapped_ion specific + } + // Should compiler warn? Infer target requirement? Just fail at codegen? + ``` + +## Implementation Plan + +### Phase 1: Gate Declaration Infrastructure +- Add `declare gate` syntax to parser +- Move standard gates to `std/gates.zlp` as declarations +- Target capability registry (what gates each target supports) +- Compile-time validation: error if target doesn't support used gate + +### Phase 2: Composite Gates +- Add `gate` keyword for composite definitions +- Inline expansion at call sites +- Type checking for arity (angles, qubits) + +### Phase 3: Noise Attributes +- Add `@atomic` / `@decomposed` attributes for composites +- SLR output with noise hints +- Noise model integration in PECOS + +### Phase 4: Advanced Features +- Qubit array parameters +- Composite gates calling other composites +- Inlining control (`@inline`, `@noinline`) +- `@target` hint attribute for documentation/validation + +--- + +## Summary + +Zlup has **no built-in quantum operations**. All quantum operations are either: + +| Type | Syntax | Body | Provider | +|------|--------|------|----------| +| Target | `declare gate name(...)` | None | Hardware, simulator, or noise model | +| Composite | `gate name(...) { }` | Required | Zlup code (sequences of other gates) | + +**Target gates** are provided by the compilation target. The standard library +(`std.gates`) declares the common operation set that most targets support: +- Preparation: `pz`, `px`, `py` +- Measurement: `mz`, `mx`, `my` +- Gates: `h`, `x`, `cx`, `rz`, etc. + +If a target doesn't support an operation, compilation fails - no fallbacks, no hidden magic. + +**Composite gates** are defined in Zlup as sequences of other gates. They're portable +across any target that supports their constituent gates. Noise models can treat them +as atomic units or decompose them. + +**Only two things are built-in:** +- `qalloc` - resource allocation (doesn't touch quantum state) +- `result` - output emission (classical) + +Key design choices: +- **All quantum operations are target-provided** - even "standard" ones +- **One import for everything quantum** - `use std.gates.*` +- **No fallbacks** - explicit failure if target doesn't support an operation +- **Fail fast** - compile-time errors, not runtime surprises +- **Atomic noise by default** for composites +- **Explicit attributes** to override noise/inlining behavior diff --git a/exp/zlup/docs/future/guppy-compat.md b/exp/zlup/docs/future/guppy-compat.md new file mode 100644 index 000000000..05633064f --- /dev/null +++ b/exp/zlup/docs/future/guppy-compat.md @@ -0,0 +1,280 @@ +# Guppy-Zlup Compatibility Layer + +This document outlines the strategy for Guppy ↔ Zlup interoperability. + +## Key Insight: The Linter is Valuable Independently + +Even if Zlup never sees wide adoption, a **Guppy linter enforcing NASA Power of 10 constraints** would be valuable on its own. It would: + +- Improve reliability of production Guppy code +- Catch common bugs (unbounded loops, recursion, dynamic allocation) +- Establish best practices for QEC code quality +- Create a "reliable Guppy" subset for mission-critical applications + +**The linter is the contribution, Zlup conversion is a bonus.** + +## The Challenge + +- Guppy is Python-embedded, Zlup is Rust-native +- Direct FFI between Python and Zlup is complex +- Zlup is more restrictive than Guppy (NASA Power of 10) +- Not all Guppy programs can become Zlup programs + +## Strategy: NASA Power of 10 Guppy (with Optional Zlup Conversion) + +Rather than trying to make arbitrary Guppy work with Zlup, we define a **constrained subset of Guppy** that maps cleanly to Zlup. This is enforced by a linter. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Full Guppy │ +│ (Python-embedded, linear types, full flexibility) │ +└─────────────────────────┬───────────────────────────────────┘ + │ guppy-lint --zlup-compat +┌─────────────────────────▼───────────────────────────────────┐ +│ "Zlup-Compatible Guppy" │ +│ • Bounded loops only (no while True) │ +│ • No recursion │ +│ • Explicit resource management │ +│ • Fixed allocations at function entry │ +│ • No dynamic Python features │ +└─────────────────────────┬───────────────────────────────────┘ + │ guppy-to-zlup (mechanical transform) +┌─────────────────────────▼───────────────────────────────────┐ +│ Zlup │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Zlup-Compatible Guppy Constraints + +### 1. Bounded Loops Only + +```python +# ❌ NOT Zlup-compatible +while condition: + do_something() + +# ❌ NOT Zlup-compatible +while True: + if done: + break + +# ✅ Zlup-compatible +for i in range(100): + do_something() + +# ✅ Zlup-compatible (with early exit) +for i in range(100): + if done: + break +``` + +**Lint message:** +``` +error[ZLUP001]: unbounded loop not Zlup-compatible + --> circuit.py:42 + | +42 | while condition: + | ^^^^^ use bounded `for i in range(N)` instead + | +help: replace with bounded loop + | +42 | for _ in range(MAX_ITERATIONS): +43 | if not condition: break +``` + +### 2. No Recursion + +```python +# ❌ NOT Zlup-compatible +def factorial(n): + if n <= 1: + return 1 + return n * factorial(n - 1) + + +# ✅ Zlup-compatible +def factorial(n): + result = 1 + for i in range(1, n + 1): + result *= i + return result +``` + +**Lint message:** +``` +error[ZLUP002]: recursive function not Zlup-compatible + --> math.py:5 + | + 5 | return n * factorial(n - 1) + | ^^^^^^^^^ recursive call + | +help: convert to iterative form with bounded loop +``` + +### 3. Fixed Allocations + +```python +# ❌ NOT Zlup-compatible (dynamic allocation in loop) +for round in range(100): + qubits = allocate(compute_size(round)) # Size varies! + +# ✅ Zlup-compatible (fixed allocation) +qubits = allocate(MAX_SIZE) +for round in range(100): + use_qubits(qubits[: needed_size(round)]) +``` + +### 4. No Dynamic Python Features + +```python +# ❌ NOT Zlup-compatible +gate_name = "h" if condition else "x" +getattr(circuit, gate_name)(qubit) # Dynamic dispatch + +# ✅ Zlup-compatible +if condition: + circuit.h(qubit) +else: + circuit.x(qubit) +``` + +## Linter Implementation + +The linter would be a Guppy plugin or standalone tool: + +```bash +# Check if Guppy code is Zlup-compatible +guppy-lint --zlup-compat circuit.py + +# Generate Zlup from compatible Guppy +guppy-to-zlup circuit.py -o circuit.zlp +``` + +### Lint Rules + +| Rule | Description | Severity | +|------|-------------|----------| +| ZLUP001 | Unbounded loop | Error | +| ZLUP002 | Recursive function | Error | +| ZLUP003 | Dynamic allocation in loop | Error | +| ZLUP004 | Dynamic dispatch | Error | +| ZLUP005 | Unchecked error | Warning | +| ZLUP006 | Missing type annotation | Warning | +| ZLUP007 | Complex control flow | Warning | + +## Transformation Errors + +When `guppy-to-zlup` cannot transform code, it provides actionable feedback: + +``` +error: cannot transform to Zlup + --> syndrome.py:78 + | +78 | while defects: + | ^^^^^ unbounded loop + | +help: this pattern often indicates MWPM-style iteration + consider moving this logic to a Rust decoder: + + 1. Create a Rust function implementing the algorithm + 2. Export via zlup-ffi with #[zlup_export] + 3. Call from Zlup: `correction := mwpm_decode(syndrome);` + + See: docs/rust-integration.md +``` + +## HUGR as Intermediate + +For cases where direct transformation isn't possible, HUGR provides an intermediate: + +``` +Guppy → HUGR → (optimize) → Zlup +``` + +This allows: +- Guppy-native optimizations before Zlup conversion +- Shared optimization passes between Guppy and Zlup +- Gradual migration path + +## Benefits + +1. **Gradual Adoption**: Teams can lint existing Guppy code toward Zlup compatibility +2. **Clear Errors**: When code can't be converted, users know exactly why +3. **Best of Both**: Research in Guppy, production in Zlup +4. **NASA Power of 10 for Guppy**: The linter brings reliability principles to Python QEC code + +## Standalone Value: guppy-lint Without Zlup + +The linter is useful **even without any Zlup conversion**: + +```bash +# Just lint for reliability, no Zlup involved +guppy-lint --strict circuit.py +``` + +### Use Cases + +| Use Case | Zlup Needed? | Value | +|----------|--------------|-------| +| Catch unbounded loops | No | Prevent hangs in production | +| Flag recursion | No | Predictable stack usage | +| Detect dynamic allocation in hot paths | No | Consistent memory usage | +| Enforce explicit error handling | No | Robust fault tolerance | +| Require type annotations | No | Better static analysis | +| **Convert to Zlup** | Yes | Rust-native execution | + +### "Reliable Guppy" Subset + +The linter effectively defines a **"Reliable Guppy"** subset: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Full Guppy │ +│ (All Python flexibility, linear types) │ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ "Reliable Guppy" │ │ +│ │ (Bounded, predictable, NASA Power of 10) │ │ +│ │ │ │ +│ │ • Production QEC systems │ │ +│ │ • Mission-critical code │ │ +│ │ • Code that needs formal analysis │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ Zlup-Convertible │ │ │ +│ │ │ (Can mechanically transform to Zlup) │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +Teams can choose their level: +1. **Full Guppy**: Maximum flexibility for research/prototyping +2. **Reliable Guppy**: NASA Power of 10 for production reliability +3. **Zlup-Convertible**: When Rust-native execution is needed + +### Integration with Guppy Ecosystem + +The linter could be: +- A **Guppy plugin** (`guppy.lint.nasa_p10`) +- A **pre-commit hook** for CI/CD +- An **IDE extension** for real-time feedback +- A **standalone tool** for auditing + +This makes reliability opt-in and gradual - teams can adopt constraints incrementally without committing to Zlup. + +## Implementation Roadmap + +### Phase 1: Standalone Guppy Linter +- Implement lint rules for NASA Power of 10 constraints +- Good error messages with actionable suggestions +- Integration with existing Python tooling (pylint, flake8 plugin?) + +### Phase 2: Guppy → Zlup Conversion (Optional) +- Mechanical transformation for compliant code +- Clear errors when conversion isn't possible +- HUGR as intermediate where helpful + +### Phase 3: Round-Trip (Future) +- Zlup → Guppy for interop +- Shared HUGR representation diff --git a/exp/zlup/docs/future/stdlib-design.md b/exp/zlup/docs/future/stdlib-design.md new file mode 100644 index 000000000..eedc75c2d --- /dev/null +++ b/exp/zlup/docs/future/stdlib-design.md @@ -0,0 +1,266 @@ +# Standard Library Design + +This document outlines the design of Zlup's standard library. + +## Import Syntax + +Zlup uses **Zig-style import semantics** with **Rust/Python-inspired syntax**: + +```zlup_nocheck +// Import entire module (Zig semantics) +std := @import("std"); + +// Use qualified access +angle := std.math.pi_4; +count := std.bits.popcount_u64(syndrome); + +// Import specific items (Rust-style syntax, Zig semantics) +// The module is still loaded, but we bind specific names +math := @import("std").math; +pi := math.pi; + +// Alternative: destructuring import (Python-style syntax) +// Still Zig semantics - the module is evaluated once +{ pi, tau, e } := @import("std").math; +``` + +### Why Zig Semantics? + +- **Comptime evaluation**: Imports are resolved at compile time +- **No runtime overhead**: Module code runs once during compilation +- **Explicit dependencies**: Clear what each file imports +- **Deterministic**: Same import always gives same result + +### Syntax Comparison + +| Style | Syntax | Zlup Approach | +|-------|--------|---------------| +| Zig | `const std = @import("std");` | `std := @import("std");` | +| Rust | `use std::math::pi;` | `{ pi } := @import("std").math;` | +| Python | `from std.math import pi` | `{ pi } := @import("std").math;` | + +## Standard Library Structure + +``` +std/ +├── math.zlp # Mathematical constants and functions +├── bits.zlp # Bitwise operations +├── mem.zlp # Memory utilities (bounded) +├── fmt.zlp # Formatting utilities +├── io.zlp # I/O (constrained, no unbounded reads) +├── debug.zlp # Debug utilities +├── testing.zlp # Test framework +├── qec/ # QEC-specific utilities +│ ├── syndrome.zlp +│ ├── pauli.zlp +│ └── codes.zlp +└── ffi.zlp # FFI helpers +``` + +## Module Contents + +### std.math + +Mathematical constants and pure functions. + +```zlup_nocheck +// Constants +pub pi: f64 = 3.14159265358979323846; +pub tau: f64 = 6.28318530717958647692; // 2π +pub e: f64 = 2.71828182845904523536; +pub sqrt2: f64 = 1.41421356237309504880; +pub sqrt2_inv: f64 = 0.70710678118654752440; // 1/√2 + +// Angle fractions (for quantum gates) +pub pi_2: f64 = 1.57079632679489661923; // π/2 +pub pi_4: f64 = 0.78539816339744830962; // π/4 +pub pi_8: f64 = 0.39269908169872415481; // π/8 + +// Conversion +pub deg_to_rad: f64 = 0.01745329251994329577; // π/180 +pub rad_to_deg: f64 = 57.29577951308232087680; // 180/π + +// Functions (comptime-evaluable where possible) +pub fn abs(x: f64) -> f64 { ... } +pub fn min(a: f64, b: f64) -> f64 { ... } +pub fn max(a: f64, b: f64) -> f64 { ... } +pub fn clamp(x: f64, lo: f64, hi: f64) -> f64 { ... } +``` + +### std.bits + +Bitwise operations for syndrome processing. + +```zlup_nocheck +// Popcount (count set bits) +pub fn popcount_u8(x: u8) -> u8 { ... } +pub fn popcount_u16(x: u16) -> u16 { ... } +pub fn popcount_u32(x: u32) -> u32 { ... } +pub fn popcount_u64(x: u64) -> u64 { ... } + +// Parity (XOR of all bits) +pub fn parity_u8(x: u8) -> u1 { ... } +pub fn parity_u16(x: u16) -> u1 { ... } +pub fn parity_u32(x: u32) -> u1 { ... } +pub fn parity_u64(x: u64) -> u1 { ... } + +// Bit extraction +pub fn get_bit(x: u64, index: u6) -> u1 { ... } +pub fn set_bit(x: u64, index: u6, value: u1) -> u64 { ... } +pub fn extract_bits(x: u64, start: u6, len: u6) -> u64 { ... } + +// Rotation +pub fn rotl_u32(x: u32, n: u5) -> u32 { ... } +pub fn rotr_u32(x: u32, n: u5) -> u32 { ... } +pub fn rotl_u64(x: u64, n: u6) -> u64 { ... } +pub fn rotr_u64(x: u64, n: u6) -> u64 { ... } + +// Leading/trailing zeros +pub fn clz_u32(x: u32) -> u6 { ... } // count leading zeros +pub fn ctz_u32(x: u32) -> u6 { ... } // count trailing zeros +pub fn clz_u64(x: u64) -> u7 { ... } +pub fn ctz_u64(x: u64) -> u7 { ... } +``` + +### std.mem + +Bounded memory utilities (NASA Power of 10 compliant). + +```zlup_nocheck +// Fixed-capacity stack +pub fn Stack(comptime T: type, comptime capacity: usize) -> type { + return struct { + items: [capacity]T = undefined, + len: usize = 0, + + pub fn push(&mut self, item: T) -> CapacityError!unit { ... } + pub fn pop(&mut self) -> ?T { ... } + pub fn peek(&self) -> ?*const T { ... } + pub fn is_empty(&self) -> bool { ... } + pub fn is_full(&self) -> bool { ... } + }; +} + +// Fixed-capacity queue (ring buffer) +pub fn Queue(comptime T: type, comptime capacity: usize) -> type { ... } + +// Fixed-capacity hash map +pub fn HashMap(comptime K: type, comptime V: type, comptime capacity: usize) -> type { ... } + +// Copying and comparison +pub fn copy(comptime T: type, dest: []T, src: []const T) -> usize { ... } +pub fn eql(comptime T: type, a: []const T, b: []const T) -> bool { ... } +pub fn set(comptime T: type, dest: []T, value: T) -> unit { ... } +``` + +### std.qec + +QEC-specific utilities. + +```zlup_nocheck +// Syndrome buffer for multi-round storage +pub fn SyndromeBuffer(comptime bits: usize, comptime rounds: usize) -> type { + return struct { + data: [rounds]u64 = undefined, + current_round: usize = 0, + + pub fn record(&mut self, syndrome: u64) -> unit { ... } + pub fn get(&self, round: usize) -> u64 { ... } + pub fn diff(&self, round_a: usize, round_b: usize) -> u64 { ... } + }; +} + +// Pauli frame tracking +pub fn PauliFrame(comptime num_qubits: usize) -> type { + return struct { + x_frame: u64 = 0, // X corrections to track + z_frame: u64 = 0, // Z corrections to track + + pub fn apply_x(&mut self, qubit: usize) -> unit { ... } + pub fn apply_z(&mut self, qubit: usize) -> unit { ... } + pub fn propagate_cx(&mut self, control: usize, target: usize) -> unit { ... } + }; +} + +// Lookup table decoder (for small codes) +pub fn LookupDecoder(comptime syndrome_bits: usize, comptime correction_bits: usize) -> type { + return struct { + table: [1 << syndrome_bits]u64 = undefined, + + pub fn init(table_data: [1 << syndrome_bits]u64) -> Self { ... } + pub fn decode(&self, syndrome: u64) -> u64 { ... } + }; +} +``` + +### std.testing + +Test framework for Zlup programs. + +```zlup_nocheck +// Test declaration (comptime) +pub fn expect(ok: bool) -> TestError!unit { + if (!ok) return error.ExpectFailed; + return; +} + +pub fn expectEq(comptime T: type, expected: T, actual: T) -> TestError!unit { + if (expected != actual) return error.ExpectEqFailed; + return; +} + +pub fn expectApprox(expected: f64, actual: f64, tolerance: f64) -> TestError!unit { + if (@abs(expected - actual) > tolerance) return error.ExpectApproxFailed; + return; +} + +// Test blocks in source files +test "syndrome parity" { + syndrome: u8 = 0b10101010; + try expect(std.bits.parity_u8(syndrome) == 0); +} +``` + +### std.ffi + +Helpers for FFI with Rust/C. + +```zlup_nocheck +// Opaque pointer wrapper +pub fn Opaque(comptime name: []const u8) -> type { + return struct { + ptr: *anyopaque, + + pub fn is_null(&self) -> bool { + return self.ptr == null; + } + }; +} + +// C string utilities +pub fn c_str_len(s: [*:0]const u8) -> usize { ... } +pub fn c_str_to_slice(s: [*:0]const u8) -> []const u8 { ... } +``` + +## Design Principles + +1. **Comptime-first**: Prefer compile-time evaluation where possible +2. **Bounded**: All containers have fixed capacity (NASA Power of 10) +3. **No allocations**: Standard library never allocates at runtime +4. **Pure functions**: Math and bit operations are pure, side-effect free +5. **QEC-focused**: Include primitives that QEC workflows commonly need +6. **FFI-friendly**: Types that work well across the Rust boundary + +## Implementation Priority + +| Module | Priority | Rationale | +|--------|----------|-----------| +| std.math | High | Gates need angle constants | +| std.bits | High | Syndrome processing | +| std.qec | High | Core QEC workflows | +| std.mem | Medium | Bounded containers | +| std.testing | Medium | Quality assurance | +| std.ffi | Medium | Rust integration | +| std.fmt | Lower | Nice to have | +| std.io | Lower | Constrained I/O | +| std.debug | Lower | Development aid | diff --git a/exp/zlup/docs/ide-setup.md b/exp/zlup/docs/ide-setup.md new file mode 100644 index 000000000..fe1d4a6d7 --- /dev/null +++ b/exp/zlup/docs/ide-setup.md @@ -0,0 +1,247 @@ +# Zlup IDE Setup + +This guide covers setting up IDE support for Zlup development, including syntax highlighting and LSP features (diagnostics, hover, completions). + +## Building the LSP Server + +The Zlup LSP server (`zlups`) provides diagnostics, hover information, and basic completions. + +```bash +# From the repository root +cargo build --features "cli lsp" --bin zlups --release + +# The binary will be at: +# target/release/zlups +``` + +## Neovim + +### Prerequisites + +- Neovim 0.8+ with LSP support +- [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig) + +### Setup + +1. **Register the filetype** in your `init.lua`: + +```lua +vim.filetype.add { + extension = { + zlp = 'zlup', + }, +} +``` + +2. **Configure the LSP** in your plugin configuration (e.g., `lua/custom/plugins/zlup.lua`): + +```lua +return { + 'neovim/nvim-lspconfig', + config = function() + local lspconfig = require 'lspconfig' + local configs = require 'lspconfig.configs' + + -- Register zlups as a custom LSP server + if not configs.zlups then + configs.zlups = { + default_config = { + cmd = { '/path/to/PECOS-alt/target/release/zlups' }, + filetypes = { 'zlup' }, + root_dir = lspconfig.util.root_pattern('zlup.toml', '.git'), + settings = {}, + }, + } + end + + -- Start the server + lspconfig.zlups.setup {} + end, +} +``` + +Replace `/path/to/PECOS-alt` with the actual path to your PECOS repository. + +3. **Verify** by opening a `.zlp` file and running `:LspInfo`. You should see `zlups` attached. + +### Troubleshooting + +- **LSP not starting**: Check that the `zlups` binary exists and is executable +- **No diagnostics**: Restart the LSP with `:LspRestart zlups` +- **Check logs**: `:LspLog` shows LSP communication logs + +## JetBrains IDEs (RustRover, IntelliJ, CLion, etc.) + +There are two options for JetBrains IDE support: + +### Option 1: LSP4IJ Plugin (Recommended) + +[LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij) is a generic LSP client plugin that works with any JetBrains IDE. + +1. **Install LSP4IJ** from the JetBrains Marketplace: + - Settings → Plugins → Marketplace → Search "LSP4IJ" → Install + +2. **Configure the LSP server**: + - Settings → Languages & Frameworks → LSP4IJ → Language Servers + - Add a new server: + - **Name**: `zlups` + - **Command**: `/path/to/PECOS-alt/target/release/zlups` + - **File patterns**: `*.zlp` + +3. **Associate the file type**: + - Settings → Editor → File Types + - Add `*.zlp` to a text-based file type (or create a new one called "Zlup") + +### Option 2: Zlup Plugin (Syntax Highlighting Only) + +A basic JetBrains plugin is available at `exp/zlup/editors/jetbrains-zlup/`. This provides syntax highlighting but not LSP features. + +#### Building the Plugin + +```bash +cd exp/zlup/editors/jetbrains-zlup +./gradlew buildPlugin + +# The plugin ZIP will be at: +# build/distributions/jetbrains-zlup-0.1.0.zip +``` + +#### Installing + +1. Settings → Plugins → Gear icon → Install Plugin from Disk +2. Select `jetbrains-zlup-0.1.0.zip` +3. Restart the IDE + +#### Features + +- Syntax highlighting for keywords, types, gates, comments +- File type registration for `.zlp` files + +For full LSP support (diagnostics, hover), combine with LSP4IJ. + +## VS Code + +### Quick Setup (No Extension Required) + +You can use a generic LSP client extension to get Zlup support without creating a custom extension. + +#### Using vscode-glspc (Generic LSP Client) + +1. **Install** [Generic LSP Client](https://marketplace.visualstudio.com/items?itemName=AaaronSun.vscode-glspc) from the marketplace + +2. **Add to your `settings.json`**: + +```json +{ + "glspc.serverPath": "/path/to/PECOS-alt/target/release/zlups", + "glspc.languageId": "zlup", + "files.associations": { + "*.zlp": "zlup" + } +} +``` + +#### Using lsp-client Extension + +Alternatively, use [LSP Client](https://marketplace.visualstudio.com/items?itemName=AperiodicSierra.lsp-client): + +1. **Install** the extension +2. **Configure in `settings.json`**: + +```json +{ + "lsp-client.serverCommand": "/path/to/PECOS-alt/target/release/zlups", + "lsp-client.fileExtensions": [".zlp"], + "files.associations": { + "*.zlp": "plaintext" + } +} +``` + +### Basic Syntax Highlighting + +For syntax highlighting without a full extension, add to `settings.json`: + +```json +{ + "editor.tokenColorCustomizations": { + "textMateRules": [ + { + "scope": "keyword.control.zlup", + "settings": { "foreground": "#C586C0" } + } + ] + } +} +``` + +For full syntax highlighting, a TextMate grammar or custom extension would be needed. + +### Creating a Full Extension (Advanced) + +For a complete VS Code extension with syntax highlighting and LSP: + +1. Use `yo code` to scaffold a language extension +2. Add a TextMate grammar for `.zlp` files +3. Configure the LSP client in `extension.ts`: + +```typescript +import * as vscode from 'vscode'; +import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node'; + +export function activate(context: vscode.ExtensionContext) { + const serverOptions: ServerOptions = { + command: '/path/to/PECOS-alt/target/release/zlups', + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [{ scheme: 'file', language: 'zlup' }], + }; + + const client = new LanguageClient('zlups', 'Zlup Language Server', serverOptions, clientOptions); + client.start(); +} +``` + +Contributions for a full VS Code extension are welcome! + +## LSP Features + +The `zlups` server currently provides: + +| Feature | Status | +|---------|--------| +| Diagnostics (errors/warnings) | Supported | +| Hover (type information) | Supported | +| Go to Definition | Supported | +| Completions | Context-aware | +| Formatting | Supported | + +### Feature Details + +**Go to Definition**: Jump to where variables, functions, and types are defined. Use `gd` in Neovim or Ctrl+Click in JetBrains/VS Code. + +**Context-aware Completions**: +- After `.` on allocators: suggests `child()`, `release()`, etc. +- After `:` or `->`: suggests types (`u32`, `void`, `bool`, etc.) +- General context: keywords, quantum gates, built-in functions + +**Formatting**: Formats code with consistent indentation and spacing. Use `:lua vim.lsp.buf.format()` in Neovim or the IDE's format command. + +## Development + +### Rebuilding After Changes + +When modifying the parser, semantic analyzer, or LSP server: + +```bash +cargo build --features "cli lsp" --bin zlups --release +``` + +Then restart the LSP in your editor: +- Neovim: `:LspRestart zlups` +- JetBrains: Restart the IDE or disable/enable the LSP server + +### Testing LSP + +A test file is provided at `exp/zlup/examples/test_lsp.zlp` for verifying LSP functionality. diff --git a/exp/zlup/docs/index.md b/exp/zlup/docs/index.md new file mode 100644 index 000000000..6d563cf0f --- /dev/null +++ b/exp/zlup/docs/index.md @@ -0,0 +1,269 @@ +# Zlup + +> **EXPERIMENTAL / EXPLORATORY** - Zlup is a research experiment, not a production language. + +A quantum programming language for QEC research: simple, low-level, and predictable by design. + +## Overview + +Zlup is the **low-level complement to Guppy** in the PECOS ecosystem. While Guppy provides a high-level, Pythonic experience with linear types for safety, Zlup explores a different approach: + +| | Guppy | Zlup | +|---|---|---| +| **Philosophy** | High-level, Pythonic | Low-level, explicit | +| **Safety mechanism** | Linear type system | Constraints make unsafe impossible | +| **Target users** | QEC researchers | Systems programmers | + +## Quick Example + +```zlup +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Create GHZ state + h q[0]; + cx (q[0], q[1]); + cx (q[1], q[2]); + cx (q[2], q[3]); + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + // Emit results to runtime + result("measurements", results); + + return; +} +``` + +## A Closer Look: QEC Workflow + +Here's a more complete program that shows off much of the language. It implements a +simple repetition code QEC workflow: encoding, multiple syndrome extraction rounds, +decoding, correction, and result emission. + +```zlup_nocheck +/// Repetition Code QEC Demo +/// +/// Demonstrates Zlup's features through a 3-qubit bit-flip code: +/// structs, error/fault sets, child allocators, tick blocks, +/// measurements, bounded loops, control flow, and more. + +std := @import("std"); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Packed syndrome from two stabilizer measurements. +Syndrome := struct { + bits: u2, // two parity bits packed into a u2 + + /// Decode syndrome to a qubit index (or none if no error). + pub fn error_location(&self) -> ?u2 { + return switch (self.bits) { + 0b00 => none, // no error + 0b10 => 0, // error on data[0] + 0b11 => 1, // error on data[1] + 0b01 => 2, // error on data[2] + }; + } +}; + +/// Stats collected across rounds. +RoundStats := struct { + rounds_run: u32, + corrections: u32, +}; + +// --------------------------------------------------------------------------- +// Error and fault sets +// --------------------------------------------------------------------------- + +/// Classical errors — something unexpected in the logic. +DecodeError := error { AmbiguousSyndrome }; + +/// Quantum faults — expected hardware imperfections. +HwFault := fault { Leakage, Crosstalk }; + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/// Apply a bit-flip correction to a single data qubit. +fn apply_correction(data: *Allocator, idx: u2) -> unit { + x data[idx]; + @emit.log.debug(f"corrected qubit {idx}"); + return; +} + +/// Measure the two Z-stabilizers of the 3-qubit repetition code. +/// +/// Uses child allocators to separate data and ancilla concerns, +/// tick blocks to express parallelism, and pack measurement. +fn measure_syndrome(data: *Allocator, ancilla: *Allocator) -> Syndrome { + // Reset ancillas before each round + pz ancilla; + + // Stabilizer circuits run in parallel where possible + @attr(round, "syndrome") + tick stabilizers { + // Z₀Z₁ stabilizer + cx (data[0], ancilla[0]); + cx (data[1], ancilla[0]); + + // Z₁Z₂ stabilizer + cx (data[1], ancilla[1]); + cx (data[2], ancilla[1]); + } + + // Measure ancillas, packing two bits into a u2 + bits: u2 = mz(pack u2) [ancilla[0], ancilla[1]]; + return Syndrome { bits }; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +pub fn main() -> unit { + // Set up a reproducible simulation + @emit.sim.send("seed", 42); + @emit.sim.send("noise_model", "depolarizing"); + @emit.sim.send("noise_rate", 0.001); + + // --- Allocate qubits with child partitioning --- + mut q := qalloc(5); + data := q.child(3); // data qubits [0..3) + ancilla := q.child(2); // ancilla qubits [3..5) + + // Prepare everything to |0⟩ + pz q; + + // --- Encode: create the logical |+⟩ state --- + h data[0]; + // Spread with CNOTs (inline for unrolls at compile time) + inline for i in 1..3 { + cx (data[0], data[i]); + } + + // --- Run several syndrome extraction rounds --- + num_rounds := 4; + mut stats := RoundStats { rounds_run: 0, corrections: 0 }; + + for round in 0..num_rounds { + syndrome := measure_syndrome(&data, &ancilla); + + @emit.log.info(f"round {round}: syndrome = 0b{syndrome.bits:02b}"); + + // Decode and maybe correct + if loc := syndrome.error_location() { + apply_correction(&data, loc); + stats.corrections += 1; + } + + stats.rounds_run += 1; + } + + // --- Final readout --- + @emit.sim.noise_disable(); // noiseless final measurement + + final: [3]u1 = mz([3]u1) [data[0], data[1], data[2]]; + parity := std.parity_u8(final[0] ^ final[1] ^ final[2]); + + // Emit results to the runtime + result("qec/final_readout", final); + result("qec/parity", parity); + result("qec/stats/rounds", stats.rounds_run); + result("qec/stats/corrections", stats.corrections); + + return; +} +``` + +**Features shown above:** + +| Feature | Where | +|---|---| +| Doc comments (`///`) | Top of file, on structs and functions | +| Imports (`@import`) | `std := @import("std")` | +| Structs with methods | `Syndrome`, `RoundStats` | +| Error and fault sets | `DecodeError`, `HwFault` | +| Allocators & children | `qalloc(5)`, `.child(3)`, `.child(2)` | +| Named tick blocks | `tick stabilizers { ... }` | +| Attributes | `@attr(round, "syndrome")` | +| Single & two-qubit gates | `h`, `x`, `cx` | +| `inline for` (comptime unroll) | `inline for i in 1..3 { cx ... }` | +| Bounded `for` loops | `for round in 0..num_rounds { ... }` | +| Typed measurement | `mz([3]u1)`, `mz(pack u2)` | +| Optionals & `if`-unwrap | `if loc := syndrome.error_location()` | +| `switch` expression | Inside `error_location` | +| Mutable bindings | `mut stats`, `stats.corrections += 1` | +| F-strings | `f"round {round}: syndrome = ..."` | +| Structured logging | `@emit.log.debug(...)`, `@emit.log.info(...)` | +| Simulator control | `@emit.sim.send(...)`, `@emit.sim.noise_disable()` | +| Result emission | `result("qec/final_readout", final)` | +| Namespaced result tags | `"qec/stats/rounds"` | +| Standard library call | `std.parity_u8(...)` | +| Pointer parameters | `fn apply_correction(data: *Allocator, ...)` | + +For the full language reference, see [Syntax](syntax.md). For error handling +details (faults vs errors, `try`/`catch`, error unions), see the +[Error Handling Guide](tutorial-error-handling.md). + +## Design Philosophy + +**Simple. Explicit. No magic.** + +- **Safe by constraint**: No recursion, no escaping references, no dangling pointers +- **NASA Power of 10**: Bounded loops, fixed resources, explicit control flow +- **Zig semantics, Rust/Python syntax**: Familiar surface, powerful foundations + +## Getting Started + +- [Tutorial](tutorial.md) - Learn the basics +- [Error Handling Guide](tutorial-error-handling.md) - Faults vs errors, QEC patterns +- [CLI Reference](cli.md) - Command-line interface +- [Language Syntax](syntax.md) - Complete reference +- [IDE Setup](ide-setup.md) - Editor configuration + +## Key Features + +### Type System +- Arrays `[N]T` vs Slices `[]T` - distinct types +- Slice syntax: `arr[0..5]`, `arr[2..]`, `arr[..5]`, `arr[..]` +- Aliases for safe slice views: `alias data := q[0..4]` + +### Error Handling +- **Faults** (physical/quantum) vs **Errors** (logical/classical) +- `try` blocks collect faults, stop on errors (QEC pattern) +- `try!` blocks stop on first fault or error (strict mode) +- Error unions `Error!T` for explicit error handling + +### Quantum Operations +- Allocator-based qubit management: `q := qalloc(4)` +- Batch operations: `h {q[0], q[1], q[2]}` +- Typed measurements: `result: u8 = mz(pack u8) qubits` + +### Safety Model +- Escape analysis prevents returning references to locals +- Recursion unconditionally forbidden +- Duplicate qubit detection in parallel operations + +## Learn More + +- [Design Philosophy](design.md) - Why Zlup exists +- [Rust Integration](rust-integration.md) - FFI and native backends +- [Standard Library](stdlib.md) - Available modules +- [Error Reference](errors.md) - Compiler error messages +- [Development Notes](dev-notes.md) - Recent changes and implementation details + +## Advanced Topics + +- **Parallelism Analysis** - Use `zlup analyze` to detect parallelizable operations. See [CLI Reference](cli.md#parallelism-analysis) and [Development Notes](dev-notes.md). +- **Aliases** - The `alias` keyword creates safe slice views with overlap detection. See [Alias Design](future/alias-design.md) for details. + +## Future Designs + +- [Custom Gates](future/custom-gates-design.md) - User-defined composite gates and target-provided gates diff --git a/exp/zlup/docs/rust-integration.md b/exp/zlup/docs/rust-integration.md new file mode 100644 index 000000000..2e67494db --- /dev/null +++ b/exp/zlup/docs/rust-integration.md @@ -0,0 +1,483 @@ +# Zlup-Rust Integration Guide + +This document describes how to write Rust code that integrates with Zlup, including traits, FFI conventions, and the `zlup-ffi` crate. + +## Overview + +Zlup is designed to orchestrate quantum operations while delegating complex classical computation to native code. Rust is the preferred language for this native layer due to its safety guarantees. + +The integration story has three parts: + +1. **`zlup-ffi` crate** - Rust library providing traits, types, and macros +2. **C ABI conventions** - How Zlup calls into Rust (via `extern "C"`) +3. **Code generation** - Zlup compiler can generate Rust bindings + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Zlup Program │ +│ syndrome: u64 = mz(pack u64) [...]; │ +│ correction := decode(syndrome); // FFI call │ +└─────────────────────────┬───────────────────────────────────┘ + │ extern "C" fn decode(u64) -> u64 +┌─────────────────────────▼───────────────────────────────────┐ +│ zlup-ffi crate │ +│ #[zlup_export] │ +│ impl Decoder for MyMWPM { ... } │ +└─────────────────────────────────────────────────────────────┘ +``` + +## The `zlup-ffi` Crate + +### Installation + +```toml +# Cargo.toml +[dependencies] +zlup-ffi = "0.1" +``` + +### Core Traits + +#### `Decoder` Trait + +The primary trait for implementing decoders: + +```rust +use zlup_ffi::prelude::*; + +/// A decoder that maps syndromes to corrections. +pub trait Decoder: Send + Sync { + /// The syndrome type (typically u64 or a custom packed type) + type Syndrome: SyndromeData; + + /// The correction type (typically u64 or a custom packed type) + type Correction: CorrectionData; + + /// Decode a syndrome into a correction. + fn decode(&self, syndrome: Self::Syndrome) -> Self::Correction; + + /// Optional: decode with soft information (for ML decoders) + fn decode_soft(&self, syndrome: Self::Syndrome, soft_info: &[f32]) -> Self::Correction { + self.decode(syndrome) // Default: ignore soft info + } + + /// Optional: reset decoder state between shots + fn reset(&mut self) {} +} +``` + +#### `NoiseModel` Trait + +For simulation backends: + +```rust +/// A noise model that can be applied to quantum state. +pub trait NoiseModel: Send + Sync { + /// Apply noise after a gate operation. + fn apply_gate_noise(&self, gate: GateType, qubits: &[QubitId], rng: &mut dyn Rng); + + /// Apply measurement noise. + fn apply_measurement_noise(&self, qubit: QubitId, rng: &mut dyn Rng) -> bool; + + /// Apply idle noise for a time step. + fn apply_idle_noise(&self, qubits: &[QubitId], rng: &mut dyn Rng); +} +``` + +#### `Simulator` Trait + +For custom simulation backends: + +```rust +/// A quantum state simulator. +pub trait Simulator: Send + Sync { + /// Apply a gate to the state. + fn apply_gate(&mut self, gate: GateType, qubits: &[QubitId]); + + /// Measure a qubit in the Z basis. + fn measure_z(&mut self, qubit: QubitId) -> bool; + + /// Reset a qubit to |0⟩. + fn reset(&mut self, qubit: QubitId); + + /// Get the current state vector (for debugging). + fn state_vector(&self) -> Option<&[Complex64]> { None } +} +``` + +### FFI-Safe Types + +The crate provides FFI-safe equivalents of common types: + +```rust +use zlup_ffi::types::*; + +// Packed syndrome/correction data +pub struct PackedBits { /* ... */ } +type Syndrome64 = PackedBits<64>; +type Syndrome128 = PackedBits<128>; + +// Qubit identifiers +pub struct QubitId(u32); + +// Gate types +#[repr(C)] +pub enum GateType { + H, X, Y, Z, S, T, Sdg, Tdg, + Sx, Sy, Sz, + Rx(f64), Ry(f64), Rz(f64), + Cx, Cy, Cz, Ch, + Swap, Iswap, + Sxx, Syy, Szz, + Rzz(f64), Rxx(f64), Ryy(f64), + Ccx, +} + +// Error types for FFI +#[repr(C)] +pub struct FfiResult { + pub ok: bool, + pub value: T, + pub error_code: u32, +} +``` + +### The `#[zlup_export]` Macro + +This proc macro generates the C ABI wrappers automatically: + +```rust +use zlup_ffi::prelude::*; + +pub struct MwpmDecoder { + // decoder state +} + +#[zlup_export] +impl Decoder for MwpmDecoder { + type Syndrome = u64; + type Correction = u64; + + fn decode(&self, syndrome: u64) -> u64 { + // MWPM algorithm implementation + todo!() + } +} +``` + +The macro generates: + +```rust +// Auto-generated C ABI exports +#[no_mangle] +pub extern "C" fn mwpm_decoder_new() -> *mut MwpmDecoder { /* ... */ } + +#[no_mangle] +pub extern "C" fn mwpm_decoder_decode( + decoder: *const MwpmDecoder, + syndrome: u64, +) -> u64 { /* ... */ } + +#[no_mangle] +pub extern "C" fn mwpm_decoder_free(decoder: *mut MwpmDecoder) { /* ... */ } +``` + +### Example: Complete MWPM Decoder + +```rust +use zlup_ffi::prelude::*; + +/// Minimum Weight Perfect Matching decoder for surface codes. +pub struct MwpmDecoder { + distance: usize, + graph: MatchingGraph, +} + +impl MwpmDecoder { + pub fn new(distance: usize) -> Self { + Self { + distance, + graph: MatchingGraph::for_surface_code(distance), + } + } +} + +#[zlup_export(name = "mwpm")] +impl Decoder for MwpmDecoder { + type Syndrome = u64; + type Correction = u64; + + fn decode(&self, syndrome: u64) -> u64 { + let defects = self.syndrome_to_defects(syndrome); + let matching = self.graph.minimum_weight_matching(&defects); + self.matching_to_correction(matching) + } + + fn reset(&mut self) { + self.graph.clear_cache(); + } +} + +impl MwpmDecoder { + fn syndrome_to_defects(&self, syndrome: u64) -> Vec { + // Convert packed syndrome bits to defect graph nodes + todo!() + } + + fn matching_to_correction(&self, matching: Matching) -> u64 { + // Convert matching result to correction operators + todo!() + } +} +``` + +## Using Rust Decoders from Zlup + +### Declaring External Functions + +In Zlup, declare the external decoder interface: + +```zlup_nocheck +// Declare external decoder functions +extern "C" { + fn mwpm_new(distance: u32) -> *Decoder; + fn mwpm_decode(decoder: *Decoder, syndrome: u64) -> u64; + fn mwpm_free(decoder: *Decoder) -> unit; +} +``` + +### Using the Decoder + +```zlup_nocheck +pub fn main() -> unit { + // Initialize decoder (typically once at program start) + decoder := mwpm_new(5); // distance-5 surface code + defer mwpm_free(decoder); + + q := qalloc(25); // 25 data qubits for d=5 + ancilla := qalloc(24); // 24 syndrome qubits + + // QEC round + for round in 0..100 { + // Syndrome extraction + pz ancilla; + // ... stabilizer measurements ... + syndrome: u64 = mz(pack u64) ancilla[0..24]; + + // Decode (FFI call to Rust) + correction := mwpm_decode(decoder, syndrome); + + // Apply correction + apply_correction(q, correction); + } + + return; +} +``` + +## Build Integration + +### Linking Rust Libraries + +When compiling Zlup programs that use Rust FFI: + +```bash +# Build the Rust decoder library +cd my-decoder +cargo build --release + +# Compile Zlup with the library +zlup compile program.zlp \ + --link-lib=my_decoder \ + --lib-path=./my-decoder/target/release \ + -o program +``` + +### Cargo Workspace Setup + +Recommended project structure: + +``` +my-qec-project/ +├── Cargo.toml # Workspace root +├── decoder/ +│ ├── Cargo.toml # Rust decoder crate +│ └── src/ +│ └── lib.rs +├── zlup/ +│ ├── main.zlp # Zlup orchestration code +│ └── lib/ # Generated Zlup bindings +└── build.rs # Build script to coordinate +``` + +### Generated Bindings + +The `zlup` CLI can generate Zlup declarations from Rust code: + +```bash +# Generate Zlup bindings from Rust crate +zlup bindgen --rust ./decoder/src/lib.rs -o ./zlup/lib/decoder.zlp +``` + +This parses `#[zlup_export]` attributes and generates corresponding Zlup declarations. + +## Error Handling Across FFI + +### Rust Side + +Use `FfiResult` for fallible operations: + +```rust +#[zlup_export] +impl Decoder for MyDecoder { + // Infallible decode - preferred + fn decode(&self, syndrome: u64) -> u64 { /* ... */ } +} + +// For fallible operations, use explicit error returns +#[no_mangle] +pub extern "C" fn decoder_init( + config_ptr: *const u8, + config_len: usize, + out_decoder: *mut *mut MyDecoder, +) -> FfiResult<()> { + // Validate inputs + if config_ptr.is_null() { + return FfiResult::err(ErrorCode::NullPointer); + } + + // Safe initialization + match MyDecoder::from_config(unsafe { std::slice::from_raw_parts(config_ptr, config_len) }) { + Ok(decoder) => { + unsafe { *out_decoder = Box::into_raw(Box::new(decoder)) }; + FfiResult::ok(()) + } + Err(e) => FfiResult::err(e.into()), + } +} +``` + +### Zlup Side + +Handle FFI errors explicitly: + +```zlup_nocheck +result := decoder_init(config.ptr, config.len, &decoder); +if !result.ok { + switch (result.error_code) { + 1 => { /* handle null pointer */ }, + 2 => { /* handle invalid config */ }, + else => { /* unknown error */ }, + } +} +``` + +## Performance Considerations + +### Minimize FFI Calls + +Batch operations when possible: + +```rust +// Good: batch decode +#[no_mangle] +pub extern "C" fn mwpm_decode_batch( + decoder: *const MwpmDecoder, + syndromes: *const u64, + corrections: *mut u64, + count: usize, +) { /* ... */ } +``` + +```zlup_nocheck +// Zlup: batch call +syndromes: [100]u64 = collect_syndromes(); +corrections: [100]u64 = undefined; +mwpm_decode_batch(decoder, &syndromes, &corrections, 100); +``` + +### Avoid Allocations + +Pre-allocate buffers and reuse them: + +```rust +pub struct MwpmDecoder { + // Pre-allocated working memory + defect_buffer: Vec, + matching_buffer: Vec, +} + +impl MwpmDecoder { + fn decode(&mut self, syndrome: u64) -> u64 { + self.defect_buffer.clear(); // Reuse allocation + // ... + } +} +``` + +### Thread Safety + +Decoders implementing `Send + Sync` can be called from multiple Zlup threads: + +```rust +// Thread-safe decoder with interior mutability +pub struct ThreadSafeDecoder { + inner: RwLock, +} + +#[zlup_export(thread_safe)] +impl Decoder for ThreadSafeDecoder { + // ... +} +``` + +## Testing + +### Unit Testing in Rust + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_no_errors() { + let decoder = MwpmDecoder::new(3); + let syndrome = 0b0000; // No errors + let correction = decoder.decode(syndrome); + assert_eq!(correction, 0); // No correction needed + } + + #[test] + fn test_decode_single_error() { + let decoder = MwpmDecoder::new(3); + let syndrome = 0b0011; // Single X error + let correction = decoder.decode(syndrome); + assert_ne!(correction, 0); // Correction applied + } +} +``` + +### Integration Testing with Zlup + +```bash +# Run Zlup tests that exercise FFI +zlup test ./tests/*.zlp --link-lib=my_decoder +``` + +## Appendix: Supported Rust Types + +| Rust Type | Zlup Type | Notes | +|-----------|-----------|-------| +| `bool` | `bool` | | +| `u8`, `u16`, `u32`, `u64` | `u8`, `u16`, `u32`, `u64` | | +| `i8`, `i16`, `i32`, `i64` | `i8`, `i16`, `i32`, `i64` | | +| `f32`, `f64` | `f32`, `f64` | | +| `usize` | `usize` | Platform-dependent | +| `*const T`, `*mut T` | `*T`, `*mut T` | Raw pointers | +| `[T; N]` | `[N]T` | Fixed arrays | +| `()` | `unit` | | + +Complex types (structs, enums) must use `#[repr(C)]` for FFI safety. diff --git a/exp/zlup/docs/stdlib.md b/exp/zlup/docs/stdlib.md new file mode 100644 index 000000000..cb84c5b8c --- /dev/null +++ b/exp/zlup/docs/stdlib.md @@ -0,0 +1,483 @@ +# Zlup Standard Library Reference + +The standard library provides common data structures and utilities for quantum programming with NASA Power of 10 compliance. All containers have bounded capacity specified at compile time. + +## Importing the Standard Library + +```zlup_nocheck +std := @import("std"); + +// Type-namespaced constants (preferred) +angle := std.a64.t_angle; // T-gate angle (1/8 turn) +pi := std.f64.pi; // pi as f64 +count := std.popcount_u8(syndrome); +``` + +--- + +## Module: f64 + +Float64 constants commonly used in calculations. + +### Fundamental Constants + +| Constant | Type | Value | Description | +|----------|------|-------|-------------| +| `std.f64.pi` | f64 | 3.14159... | Pi | +| `std.f64.tau` | f64 | 6.28318... | 2*pi (full rotation in radians) | +| `std.f64.e` | f64 | 2.71828... | Euler's number | +| `std.f64.sqrt2` | f64 | 1.41421... | Square root of 2 | +| `std.f64.sqrt2_inv` | f64 | 0.70710... | 1/sqrt(2), Hadamard normalization | + +### Angle Fractions in Radians + +| Constant | Value | Degrees | Common Use | +|----------|-------|---------|------------| +| `std.f64.pi_2` | pi/2 | 90 | Quarter turn | +| `std.f64.pi_3` | pi/3 | 60 | | +| `std.f64.pi_4` | pi/4 | 45 | T-gate | +| `std.f64.pi_6` | pi/6 | 30 | | +| `std.f64.pi_8` | pi/8 | 22.5 | | + +### Conversion Factors + +| Constant | Description | Example | +|----------|-------------|---------| +| `std.f64.deg_to_rad` | Multiply degrees to get radians | `45.0 * std.f64.deg_to_rad` | +| `std.f64.rad_to_deg` | Multiply radians to get degrees | `pi_4 * std.f64.rad_to_deg` | + +--- + +## Module: a64 + +Angle constants in turns (the native unit for a64). All values are exact in the Angle64 fixed-point representation. + +### Fundamental Turn Fractions + +| Constant | Turns | Radians | Degrees | Common Use | +|----------|-------|---------|---------|------------| +| `std.a64.zero` | 0 | 0 | 0 | Identity | +| `std.a64.half_turn` | 1/2 | pi | 180 | Z-gate | +| `std.a64.quarter_turn` | 1/4 | pi/2 | 90 | S-gate | +| `std.a64.eighth_turn` | 1/8 | pi/4 | 45 | T-gate | +| `std.a64.sixteenth_turn` | 1/16 | pi/8 | 22.5 | | + +### Gate-Named Aliases + +| Constant | Equivalent | Description | +|----------|------------|-------------| +| `std.a64.t_angle` | 1/8 turn | T-gate rotation | +| `std.a64.tdg_angle` | 7/8 turn | T-dagger (negative T) | +| `std.a64.s_angle` | 1/4 turn | S-gate rotation | +| `std.a64.sdg_angle` | 3/4 turn | S-dagger (negative S) | +| `std.a64.z_angle` | 1/2 turn | Z-gate rotation | + +### Example Usage + +```zlup_nocheck +std := @import("std"); + +// Preferred: use a64 constants with turns unit +rz(std.a64.t_angle turns) q[0]; // T-gate +rz(std.a64.quarter_turn turns) q[0]; // S-gate + +// Or use f64.pi with rad unit +rz(std.f64.pi/4 rad) q[0]; // Also T-gate + +// Fraction literals work directly +rz(1/8 turns) q[0]; // Also T-gate +``` + +--- + +## Module: bits + +Bitwise utilities for working with measurement results. Essential for syndrome processing in QEC. + +### Bit Counting (Population Count) + +Count the number of set bits (1s) in a value. + +```zlup_nocheck +fn popcount_u8(x: u8) -> u8 +fn popcount_u16(x: u16) -> u16 +fn popcount_u32(x: u32) -> u32 +fn popcount_u64(x: u64) -> u64 +``` + +**Example:** +```zlup_nocheck +result: u8 = 0b10110100; +weight := std.popcount_u8(result); // 4 +``` + +### Parity + +Compute parity (XOR of all bits). Returns 0 if even number of 1s, 1 if odd. + +```zlup_nocheck +fn parity_u8(x: u8) -> u1 +fn parity_u16(x: u16) -> u1 +fn parity_u32(x: u32) -> u1 +fn parity_u64(x: u64) -> u1 +``` + +**Example:** +```zlup_nocheck +// Syndrome parity check +syndrome: u8 = mz(pack u8) ancillas; +if std.parity_u8(syndrome) == 1 { + // Odd parity - error detected +} +``` + +### Bit Extraction + +Extract a single bit at the given index (0 = LSB). + +```zlup_nocheck +fn get_bit_u8(x: u8, index: u8) -> u1 +fn get_bit_u16(x: u16, index: u16) -> u1 +fn get_bit_u32(x: u32, index: u32) -> u1 +fn get_bit_u64(x: u64, index: u64) -> u1 +``` + +**Example:** +```zlup_nocheck +result: u8 = 0b10110100; +bit2 := std.bits.get_bit_u8(result, 2); // 1 +bit0 := std.bits.get_bit_u8(result, 0); // 0 +``` + +### Bit Manipulation + +```zlup_nocheck +fn set_bit_u8(x: u8, index: u8) -> u8 // Set bit to 1 +fn clear_bit_u8(x: u8, index: u8) -> u8 // Set bit to 0 +fn toggle_bit_u8(x: u8, index: u8) -> u8 // Flip bit +``` + +**Example:** +```zlup_nocheck +x: u8 = 0b00000000; +x = std.bits.set_bit_u8(x, 3); // 0b00001000 +x = std.bits.toggle_bit_u8(x, 0); // 0b00001001 +x = std.bits.clear_bit_u8(x, 3); // 0b00000001 +``` + +### Byte Order + +```zlup_nocheck +fn reverse_bits_u8(x: u8) -> u8 // Reverse bit order +fn swap_bytes_u16(x: u16) -> u16 // Swap bytes (endianness) +fn swap_bytes_u32(x: u32) -> u32 // Swap bytes +``` + +**Example:** +```zlup_nocheck +x: u8 = 0b10110100; +reversed := std.bits.reverse_bits_u8(x); // 0b00101101 +``` + +--- + +## Module: containers + +Bounded data structures for deterministic memory usage. + +### Stack(T, capacity) + +Last-In-First-Out (LIFO) container. + +```zlup_nocheck +stack: std.Stack(u32, 64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `push` | `(&mut self, T) -> OverflowError!void` | Push item (fails if full) | +| `pop` | `(&mut self) -> ?T` | Pop item (none if empty) | +| `peek` | `(&mut self) -> ?T` | View top without removing | +| `is_empty` | `(&mut self) -> bool` | Check if empty | +| `is_full` | `(&mut self) -> bool` | Check if at capacity | +| `clear` | `(&mut self) -> unit` | Remove all items | +| `count` | `(&mut self) -> usize` | Current item count | +| `get_capacity` | `(&mut self) -> usize` | Maximum capacity | + +**Example:** +```zlup_nocheck +stack: std.Stack(u32, 64) = .{}; +try stack.push(10); +try stack.push(20); + +if val := stack.pop() { + // val is 20 +} +``` + +### Queue(T, capacity) + +First-In-First-Out (FIFO) container using a ring buffer. + +```zlup_nocheck +queue: std.Queue(u32, 64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `enqueue` | `(&mut self, T) -> OverflowError!void` | Add to back | +| `dequeue` | `(&mut self) -> ?T` | Remove from front | +| `peek_front` | `(&mut self) -> ?T` | View front without removing | +| `is_empty` | `(&mut self) -> bool` | Check if empty | +| `is_full` | `(&mut self) -> bool` | Check if at capacity | +| `clear` | `(&mut self) -> unit` | Remove all items | +| `count` | `(&mut self) -> usize` | Current item count | + +**Example:** +```zlup_nocheck +queue: std.Queue(u32, 64) = .{}; +try queue.enqueue(1); +try queue.enqueue(2); + +if val := queue.dequeue() { + // val is 1 (first in, first out) +} +``` + +### Deque(T, capacity) + +Double-ended queue supporting insertion/removal at both ends. + +```zlup_nocheck +deque: std.Deque(u32, 64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `push_back` | `(&mut self, T) -> OverflowError!void` | Add to back | +| `push_front` | `(&mut self, T) -> OverflowError!void` | Add to front | +| `pop_back` | `(&mut self) -> ?T` | Remove from back | +| `pop_front` | `(&mut self) -> ?T` | Remove from front | +| `peek_front` | `(&mut self) -> ?T` | View front | +| `peek_back` | `(&mut self) -> ?T` | View back | +| `is_empty` | `(&mut self) -> bool` | Check if empty | +| `is_full` | `(&mut self) -> bool` | Check if at capacity | +| `clear` | `(&mut self) -> unit` | Remove all items | +| `count` | `(&mut self) -> usize` | Current item count | + +### PriorityQueue(T, capacity) + +Min-heap priority queue. Smallest element is always at front. + +```zlup_nocheck +pq: std.PriorityQueue(u32, 64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `insert` | `(&mut self, T) -> OverflowError!void` | Insert with priority | +| `extract_min` | `(&mut self) -> ?T` | Remove smallest | +| `peek_min` | `(&mut self) -> ?T` | View smallest | +| `is_empty` | `(&mut self) -> bool` | Check if empty | + +**Example:** +```zlup_nocheck +pq: std.PriorityQueue(u32, 64) = .{}; +try pq.insert(50); +try pq.insert(10); +try pq.insert(30); + +if val := pq.extract_min() { + // val is 10 (smallest) +} +``` + +--- + +## Module: qec + +Quantum Error Correction utilities for decoder implementations. + +### UnionFind(capacity) + +Disjoint Set Union (DSU) data structure. Essential for MWPM decoders. + +```zlup_nocheck +uf: std.UnionFind(256) = .{}; +uf.init(); +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `init` | `(&mut self) -> unit` | Initialize all elements as separate sets | +| `find` | `(&mut self, usize) -> usize` | Find root of set (with path compression) | +| `union` | `(&mut self, usize, usize) -> bool` | Unite two sets (returns true if merged) | +| `connected` | `(&mut self, usize, usize) -> bool` | Check if in same set | +| `reset` | `(&mut self, usize) -> unit` | Reset element to own set | +| `reset_all` | `(&mut self) -> unit` | Reset all elements | + +**Example:** +```zlup_nocheck +uf: std.UnionFind(256) = .{}; +uf.init(); + +uf.union(0, 1); +uf.union(1, 2); + +if uf.connected(0, 2) { + // 0 and 2 are in the same set +} +``` + +### SyndromeBuffer(num_ancillas, max_rounds) + +Storage for syndrome measurements across multiple rounds. + +```zlup_nocheck +syndrome: std.SyndromeBuffer(16, 10) = .{}; // 16 ancillas, 10 rounds +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `clear` | `(&mut self) -> unit` | Clear all data | +| `set` | `(&mut self, ancilla, round, bool) -> unit` | Set syndrome bit | +| `get` | `(&mut self, ancilla, round) -> bool` | Get syndrome bit | +| `record_round` | `(&mut self, [N]bool) -> OverflowError!void` | Record full round | +| `num_rounds` | `(&mut self) -> usize` | Rounds recorded | +| `has_error` | `(&mut self, round) -> bool` | Any syndrome in round? | +| `count_errors` | `(&mut self, round) -> usize` | Count triggered syndromes | + +**Example:** +```zlup_nocheck +syndrome: std.SyndromeBuffer(4, 10) = .{}; + +// Record syndromes from measurement +ancilla_results: [4]bool = .{ true, false, false, true }; +try syndrome.record_round(ancilla_results); + +if syndrome.has_error(0) { + // Process errors in round 0 +} +``` + +### LookupDecoder(num_syndromes, num_corrections) + +Table-based decoder for small codes. + +```zlup_nocheck +decoder: std.LookupDecoder(16, 4) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `add_entry` | `(&mut self, syndrome, correction) -> OverflowError!void` | Add lookup entry | +| `decode` | `(&mut self, syndrome) -> ?Correction` | Lookup correction | + +### PauliFrame(num_qubits) + +Track Pauli frame for frame tracking decoders. + +```zlup_nocheck +frame: std.PauliFrame(64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `init` | `(&mut self) -> unit` | Initialize to identity | +| `apply_x` | `(&mut self, qubit) -> unit` | Apply X correction | +| `apply_z` | `(&mut self, qubit) -> unit` | Apply Z correction | +| `apply_y` | `(&mut self, qubit) -> unit` | Apply Y correction | +| `get_x` | `(&mut self, qubit) -> bool` | Check X component | +| `get_z` | `(&mut self, qubit) -> bool` | Check Z component | +| `reset` | `(&mut self, qubit) -> unit` | Reset qubit's frame | +| `reset_all` | `(&mut self) -> unit` | Reset entire frame | + +### SparseGraph(max_nodes, max_edges) + +Adjacency list graph for decoder algorithms. + +```zlup_nocheck +graph: std.SparseGraph(256, 1024) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `init` | `(&mut self) -> unit` | Initialize empty graph | +| `add_edge` | `(&mut self, u, v, weight) -> OverflowError!void` | Add weighted edge | +| `get_neighbors` | `(&mut self, node) -> []Edge` | Get adjacent edges | +| `clear` | `(&mut self) -> unit` | Remove all edges | + +--- + +## Module Structure + +The main `std` module provides type-namespaced constants and utilities: + +```zlup_nocheck +std := @import("std"); + +// Type-namespaced constants +std.f64.pi, std.f64.tau, std.f64.e // f64 constants +std.f64.sqrt2, std.f64.sqrt2_inv +std.f64.pi_2, std.f64.pi_4, std.f64.pi_8 + +std.a64.quarter_turn, std.a64.eighth_turn // a64 angle constants (in turns) +std.a64.t_angle, std.a64.s_angle // Gate-named aliases + +// Bit operations +std.popcount_u8, std.popcount_u16, std.popcount_u32, std.popcount_u64 +std.parity_u8, std.parity_u16, std.parity_u32, std.parity_u64 + +// Container types (from std.containers) +std.Stack, std.Queue, std.Deque, std.PriorityQueue + +// QEC utilities (from std.qec) +std.UnionFind, std.SyndromeBuffer, std.LookupDecoder +std.PauliFrame, std.SparseGraph +``` + +--- + +## Error Types + +### OverflowError + +Returned when a bounded container exceeds capacity. + +```zlup_nocheck +OverflowError := error { Overflow }; +``` + +**Handling:** +```zlup_nocheck +// Propagate with try +try stack.push(item); + +// Handle with catch +stack.push(item) catch { + // Handle overflow +}; + +// Check before operation +if !stack.is_full() { + try stack.push(item); +} +``` diff --git a/exp/zlup/docs/syntax.md b/exp/zlup/docs/syntax.md new file mode 100644 index 000000000..25a89f694 --- /dev/null +++ b/exp/zlup/docs/syntax.md @@ -0,0 +1,1243 @@ +# Zlup Language Reference + +Complete syntax reference for the Zlup quantum programming language. + +## Table of Contents + +- [Comments](#comments) +- [Literals](#literals) +- [Variables and Bindings](#variables-and-bindings) +- [Types](#types) +- [Operators](#operators) +- [Control Flow](#control-flow) +- [Functions](#functions) +- [Structs and Enums](#structs-and-enums) +- [Error Handling](#error-handling) +- [Quantum Operations](#quantum-operations) +- [Attributes](#attributes) +- [Modules](#modules) +- [Logging](#logging) +- [Result Emission](#result-emission) +- [Simulator Control](#simulator-control) +- [Compilation Targets](#compilation-targets) + +--- + +## Comments + +```zlup_nocheck +// Single-line comment + +/* Multi-line + comment */ + +/// Documentation comment (for declarations) +``` + +--- + +## Literals + +### Numbers + +```zlup_nocheck +// Integers +42 // Decimal +0xFF // Hexadecimal +0b1010 // Binary +0o755 // Octal +1_000_000 // Underscores for readability + +// With type suffix +42_u32 // Explicit u32 +255_u8 // Explicit u8 + +// Floats +3.14 // f64 by default +3.14_f32 // Explicit f32 +1.5e10 // Scientific notation +1e-5 // Scientific without decimal + +// Angles +1.57_a64 // Angle type for rotations +``` + +### Strings + +```zlup_nocheck +// Regular string (escape sequences processed) +"hello\nworld" + +// Raw string (no escape processing) +r"C:\Users\path" +r"\d+\.\d+" // Regex pattern + +// Multi-line string (triple-quoted) +""" +Line 1 +Line 2 +""" + +// F-string (interpolation) +f"Value: {x}" +f"Pi = {pi:.2f}" // With format specifier +f"Padded: {n:08d}" // Zero-padded +``` + +### Escape Sequences + +| Sequence | Character | +|----------|-----------| +| `\n` | Newline | +| `\r` | Carriage return | +| `\t` | Tab | +| `\\` | Backslash | +| `\"` | Double quote | +| `\'` | Single quote | +| `\0` | Null | +| `\{` | Literal `{` in f-strings | +| `\}` | Literal `}` in f-strings | +| `\xNN` | Hex byte | + +### Other Literals + +```zlup_nocheck +true, false // Booleans +none // Optional null value +undefined // Uninitialized value +unit // Unit type value +'a' // Character literal +``` + +--- + +## Variables and Bindings + +### Immutable Bindings + +```zlup_fragment +x := 42; // Type inferred +y: u32 = 100; // Type explicit +z := "hello"; // String +``` + +### Mutable Bindings + +```zlup_fragment +mut count := 0; // Mutable, type inferred +mut buffer: [10]u8 = undefined; // Mutable array +count = count + 1; // Assignment +count += 1; // Compound assignment +``` + +### Aliases + +Aliases create named views into existing data with overlap checking: + +```zlup_nocheck +arr: [8]u32 = undefined; + +// Create non-overlapping aliases +alias data := arr[0..4]; +alias ancilla := arr[4..8]; + +// Use aliases like slices +process(data); +h ancilla[0]; + +// Overlapping aliases are compile-time errors: +alias overlap := arr[2..6]; // ERROR: overlaps with 'data' +``` + +**Alias constraints:** +- Source must be a slice expression (e.g., `arr[0..4]`) +- Range bounds must be comptime-evaluable for overlap checking +- Aliases are immutable (cannot be reassigned) +- Overlapping ranges on the same source are errors + +### Assignment Operators + +| Operator | Description | +|----------|-------------| +| `=` | Assignment | +| `+=` | Add and assign | +| `-=` | Subtract and assign | +| `*=` | Multiply and assign | +| `/=` | Divide and assign | +| `&=` | Bitwise AND and assign | +| `\|=` | Bitwise OR and assign | +| `^=` | Bitwise XOR and assign | + +--- + +## Types + +### Primitive Types + +| Type | Description | +|------|-------------| +| `bool` | Boolean (true/false) | +| `uN` | Unsigned N-bit integer (N = 1-128), e.g., `u1`, `u7`, `u32`, `u128` | +| `iN` | Signed N-bit integer (N = 1-128), e.g., `i8`, `i32`, `i64` | +| `usize`, `isize` | Pointer-sized integers | +| `f16`, `f32`, `f64`, `f128` | Floating point | +| `a64` | Angle type (for gate rotations) | +| `unit` | Unit type (no value) | +| `type` | Type as a value (comptime) | +| `anytype` | Any type (comptime) | + +Like Zig, Zlup supports arbitrary-width integers from 1 to 128 bits. This is useful for: +- Bit-packed measurement results: `u1`, `u2`, `u4` +- Syndrome values: `u3` for 3-bit syndromes +- Efficient storage: use exactly the bits you need + +### Quantum Types + +| Type | Description | +|------|-------------| +| `qubit` | Single qubit | +| `bit` | Classical bit | + +### Compound Types + +```zlup_nocheck +// Arrays (fixed size, known at compile time) +[4]u32 // Array of 4 u32 +[_]u8 // Size inferred from initializer + +// Slices (dynamic view into contiguous memory) +[]u8 // Slice of u8 +[]const u8 // Immutable slice +[][]i32 // Slice of slices (2D) + +// Pointers +*u32 // Single-item pointer +[*]u8 // Many-item pointer +[*:0]u8 // Sentinel-terminated + +// Optionals +?u32 // Optional u32 + +// Error unions +Error!u32 // u32 or Error + +// Tuples +(u32, bool) // Tuple of u32 and bool +(u32, u32, u32) // 3-element tuple + +// Sets +Set(u32) // Set of u32 +``` + +### Array and Slice Operations + +```zlup_nocheck +// Indexing (returns element) +arr[0] // First element +matrix[0][1] // Nested indexing + +// Slicing (returns slice) +arr[0..5] // Elements 0 to 4 (exclusive end) +arr[2..] // From index 2 to end +arr[..5] // From start to index 4 +arr[..] // Full slice (all elements) + +// Chained slicing +arr[1..10][2..5] // Slice of a slice + +// Array to slice conversion +slice := arr[..]; // Convert array to slice +``` + +**Note:** Arrays (`[N]T`) and slices (`[]T`) are distinct types. To pass an array where a slice is expected, use `arr[..]` to create a slice view. + +### Type Expressions + +```zlup_nocheck +// Function types +fn(u32, u32) -> u32 + +// Comptime types +comptime T: type +``` + +--- + +## Operators + +### Arithmetic + +| Operator | Description | +|----------|-------------| +| `+` | Addition | +| `-` | Subtraction (or negation) | +| `*` | Multiplication | +| `/` | Division | +| `%` | Modulo | + +### Comparison + +| Operator | Description | +|----------|-------------| +| `==` | Equal | +| `!=` | Not equal | +| `<` | Less than | +| `>` | Greater than | +| `<=` | Less or equal | +| `>=` | Greater or equal | + +### Logical + +| Operator | Description | +|----------|-------------| +| `and` | Logical AND | +| `or` | Logical OR | +| `!` | Logical NOT | + +### Bitwise + +| Operator | Description | +|----------|-------------| +| `&` | Bitwise AND | +| `\|` | Bitwise OR | +| `^` | Bitwise XOR | +| `~` | Bitwise NOT | +| `<<` | Left shift | +| `>>` | Right shift | + +### Membership + +| Operator | Description | +|----------|-------------| +| `in` | Membership test | +| `not in` | Negated membership | + +```zlup_nocheck +if x in items { } +if y not in set { } +``` + +### Optional/Error Operators + +| Operator | Description | +|----------|-------------| +| `orelse` | Unwrap optional with default | +| `catch` | Handle error with default | +| `.?` | Optional unwrap (returns optional) | +| `.!` | Error unwrap | +| `try` | Propagate error | + +```zlup_nocheck +value := optional orelse default; +result := fallible() catch |err| handle(err); +``` + +--- + +## Control Flow + +### If Statements + +```zlup_nocheck +// Simple if +if condition { + // body +} + +// If-else +if condition { + // true branch +} else { + // false branch +} + +// If-else if chain +if a { + // ... +} else if b { + // ... +} else { + // ... +} + +// Optional unwrapping (walrus operator) +if value := optional { + // value is unwrapped here +} +``` + +### If Expressions + +```zlup_nocheck +// If as expression (requires parentheses and else) +result := if (condition) { value1 } else { value2 }; +``` + +### For Loops + +All loops must have bounded iteration. + +```zlup_nocheck +// Range loop +for i in 0..10 { + // i goes from 0 to 9 +} + +// With index +for i, item in items { + // i is index, item is value +} + +// Inline for (comptime unrolling) +inline for i in 0..4 { + h q[i]; +} +// Unrolls to: h q[0]; h q[1]; h q[2]; h q[3]; + +// Nested inline for +inline for i in 0..2 { + inline for j in 0..3 { + cx (q[i], q[j + 2]); + } +} +// Unrolls to 6 cx gates +``` + +**Inline for constraints:** +- Range bounds must be comptime-evaluable (literals or comptime constants) +- `break` and `continue` are not allowed inside inline for bodies +- Maximum unroll limit of 1024 iterations for safety + +### Switch Statements + +```zlup_nocheck +switch (value) { + 0 => { /* handle 0 */ }, + 1, 2 => { /* handle 1 or 2 */ }, + 3..10 => { /* handle range */ }, + else => { /* default */ }, +} +``` + +### Control Flow Keywords + +```zlup_nocheck +return value; // Return from function +return; // Return unit (shorthand for return unit;) +break; // Exit loop +break :label value; // Break with label and value +continue; // Next iteration +continue :label; // Continue outer loop +``` + +### Labeled Blocks + +```zlup_nocheck +result := blk: { + if condition { + break :blk value1; + } + break :blk value2; +}; +``` + +### Unsafe Blocks + +Unsafe blocks provide an escape hatch from strict mode constraints. They allow operations that are normally forbidden, such as recursion. Unsafe blocks are **forbidden by default** and require the `--allow-unsafe` flag. + +```zlup_nocheck +// Recursion inside unsafe block (requires --allow-unsafe) +fn factorial(n: u32) -> u32 { + unsafe { + if n <= 1 { return 1; } + return n * factorial(n - 1); + } +} +``` + +**What unsafe allows:** +- Recursive function calls +- Gates on potentially unprepared qubits +- Other strict mode violations + +**What unsafe does NOT allow:** +- Type errors (still checked) +- Undefined variables (still checked) +- Syntax errors (still checked) + +Unsafe blocks follow Rust's philosophy: make potentially dangerous operations explicit and auditable. Production code can ban all unsafe by not passing `--allow-unsafe`. + +--- + +## Functions + +### Function Declaration + +```zlup_nocheck +// Basic function +fn add(a: u32, b: u32) -> u32 { + return a + b; +} + +// Public function +pub fn main() -> unit { + return; +} + +// Inline function +inline fn square(x: u32) -> u32 { + return x * x; +} + +// Method with self receiver +fn increment(&mut self) -> unit { + self.count += 1; + return; +} + +// Comptime parameters +fn make_array(comptime N: usize) -> [N]u32 { + arr: [N]u32 = undefined; + return arr; +} +``` + +### Function Types + +```zlup_nocheck +fn_type := fn(u32, u32) -> u32; +``` + +### Anonymous Functions + +```zlup_nocheck +callback := fn(x: u32) -> u32 { + return x * 2; +}; +``` + +--- + +## Structs and Enums + +### Struct Declaration + +```zlup_nocheck +Point := struct { + x: f64, + y: f64, + + pub fn distance(&self) -> f64 { + // method implementation + } +}; +``` + +### Struct Initialization + +```zlup_nocheck +// Named fields (Rust-style) +p := Point { x: 1.0, y: 2.0 }; + +// Anonymous struct +data := .{ x: 1, y: 2 }; + +// Shorthand (when variable name matches field) +x := 1.0; +y := 2.0; +p := Point { x, y }; +``` + +### Enum Declaration + +```zlup_nocheck +Color := enum { + Red, + Green, + Blue, +}; + +// With explicit values +Status := enum(u8) { + Ok = 0, + Error = 1, +}; +``` + +### Tagged Union + +```zlup_nocheck +Value := union(enum) { + Int: i32, + Float: f64, + None, +}; +``` + +### Generic Types (Comptime) + +```zlup_nocheck +Stack := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + items: [capacity]T = undefined, + len: usize = 0, + } +}; + +// Usage +stack: Stack(u32, 64) = .{}; +``` + +--- + +## Error Handling + +### Error Sets + +```zlup_nocheck +// Define error set +FileError := error { NotFound, PermissionDenied, IoError }; + +// Error value literal +err := error.NotFound; +``` + +### Fault Sets (Quantum) + +```zlup_fragment +// Define fault set +QuantumFault := fault { Leakage, QubitLoss }; + +// Fault value literal +f := fault.Leakage; +``` + +### Error Unions + +```zlup_nocheck +// Function returning error union +fn read(path: []const u8) -> FileError![]u8 { + if !exists(path) { + return error.NotFound; + } + return data; +} +``` + +### Handling Errors + +```zlup_nocheck +// Propagate with try +data := try read("file.txt"); + +// Handle with catch +data := read("file.txt") catch |err| { + // handle error + return default; +}; + +// Default value +data := read("file.txt") catch "default"; +``` + +### Try Blocks + +```zlup_nocheck +// Collect all errors (QEC pattern) +errors := try { + risky_op1(); + risky_op2(); +}; + +// Stop on first error +result := try! { + step1(); + step2(); +} catch |err| handle(err); +``` + +### Defer + +```zlup_nocheck +fn process() -> unit { + resource := acquire(); + defer release(resource); // Runs on scope exit + + // Use resource... + return; +} + +// Error-specific defer +errdefer |err| cleanup(err); +``` + +--- + +## Quantum Operations + +### Allocators + +```zlup_fragment +// Allocate qubits +q := qalloc(4); + +// Child allocators +mut main := qalloc(10); +data := main.child(4); +ancilla := main.child(6); +``` + +### Prepare (Reset) + +```zlup_fragment +pz q; // Prepare all +pz q[0]; // Prepare one +pz {q[0], q[1]}; // Batch prepare +``` + +### Single-Qubit Gates + +```zlup_fragment +// Pauli gates +x q[0]; y q[0]; z q[0]; + +// Hadamard +h q[0]; + +// T gates (fourth root of Z) +t q[0]; tdg q[0]; + +// Square root gates (sz is the S gate, sqrt of Z) +sx q[0]; sy q[0]; sz q[0]; +sxdg q[0]; sydg q[0]; szdg q[0]; +``` + +### Rotation Gates + +```zlup_nocheck +// Parameterized rotations +rx(angle) q[0]; +ry(angle) q[0]; +rz(angle) q[0]; +``` + +### Two-Qubit Gates + +```zlup_fragment +// Controlled gates (control, target) +cx (q[0], q[1]); +cy (q[0], q[1]); +cz (q[0], q[1]); +ch (q[0], q[1]); + +// Swap +swap (q[0], q[1]); +iswap (q[0], q[1]); + +// Ising gates +sxx (q[0], q[1]); +syy (q[0], q[1]); +szz (q[0], q[1]); + +// Parameterized +rzz(angle) (q[0], q[1]); +crz(angle) (q[0], q[1]); +``` + +### Three-Qubit Gates + +```zlup_fragment +ccx (q[0], q[1], q[2]); // Toffoli +``` + +### Batch Operations + +```zlup_nocheck +// Apply same gate to multiple qubits +h {q[0], q[1], q[2]}; + +// Batch two-qubit gates +cx {(q[0], q[1]), (q[2], q[3])}; + +// Batch rotations +rx(angle) {q[0], q[1]}; +``` + +### Measurement + +```zlup_nocheck +// Single qubit +result: u1 = mz(u1) q[0]; + +// Multiple qubits into array +results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + +// Measure entire register +all_bits := mz([4]u1) q; + +// Pack into integer +syndrome: u8 = mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; + +// Pack into custom struct (for QEC syndromes, etc.) +Syndrome := struct { x_parity: u1, z_parity: u1, flags: u2 }; +syndrome := mz(pack Syndrome) [ancilla[0], ancilla[1], ancilla[2], ancilla[3]]; +``` + +The `pack` modifier fills bits sequentially into the target type's bit layout. +Without `pack`, each qubit produces one value of type T (count must match exactly). + +### Tick Blocks + +```zlup_fragment +// Group parallel operations +tick { + h q[0]; + h q[1]; +} + +// Named tick with attributes +@attr(round, 0) +tick syndrome_check { + // operations +} +``` + +--- + +## Attributes + +```zlup_nocheck +// Single attribute +@attr(key, value) + +// Multiple attributes +@attrs({key1: value1, key2: value2}) + +// On declarations +@attr(inline, true) +fn fast_op() -> unit { } + +// On tick blocks +@attr(round, 0) +tick { } +``` + +--- + +## Modules + +### Import + +```zlup_nocheck +// Import standard library +std := @import("std"); + +// Import local module +utils := @import("utils.zlup"); +``` + +### Public Exports + +```zlup_nocheck +// Public binding +pub x := 42; + +// Public function +pub fn helper() -> unit { } + +// Public type +pub Point := struct { x: f64, y: f64 }; +``` + +### Builtins + +| Builtin | Description | +|---------|-------------| +| `@import(path)` | Import module | +| `@size_of(T)` | Size of type in bytes | +| `@type_info(T)` | Returns structured type information (kind, name, fields, etc.) | +| `@type_name(T)` | Type name as string | +| `@field_names(T)` | Returns array of struct field names | +| `@enum_fields(T)` | Returns array of enum variant names | +| `@type_from_info(info)` | Construct type from TypeInfo struct (reverse of `@type_info`) | +| `@compile_error(msg)` | Compile-time error | +| `@compile_log(...)` | Compile-time debug print | + +Note: Both snake_case (Rust/Python style) and camelCase names are accepted for builtins. Snake_case is preferred. + +--- + +## Logging + +Zlup provides built-in structured logging with namespace filtering. + +### Basic Logging + +```zlup_nocheck +// Standard log levels +@emit.log.trace(f"detailed trace"); +@emit.log.debug(f"debug info"); +@emit.log.info(f"general info"); +@emit.log.warn(f"warning"); +@emit.log.error(f"error"); +``` + +### With Namespace + +```zlup_nocheck +// Sub-namespace for filtering +@emit.log.debug("decoder", f"processing syndrome"); +@emit.log.info("qec::round", f"round complete"); +``` + +### With Structured Data + +```zlup_nocheck +// Attach data for structured logging +@emit.log.debug(f"state", data: current_state); +@emit.log.info(f"result", data: measurement_results); +``` + +### Custom Log Levels + +```zlup_nocheck +// Numeric levels for fine-grained control +@emit.log.at(15, f"between trace and debug"); +@emit.log.at(25, "perf", f"timing metric"); +``` + +### Log Levels + +| Level | Priority | Description | +|-------|----------|-------------| +| `trace` | 0 | Very detailed tracing | +| `debug` | 100 | Debug information | +| `info` | 200 | General information | +| `warn` | 300 | Warnings | +| `error` | 400 | Errors | + +### Runtime Filtering (ZLUP_LOG) + +```bash +# All logs at debug and above +ZLUP_LOG=debug ./program + +# Only errors +ZLUP_LOG=error ./program + +# By namespace +ZLUP_LOG=mymodule=trace ./program +ZLUP_LOG=mymodule::decoder=debug ./program +``` + +### Compile-Time Elision + +```bash +# Release mode - remove all logs +zlup compile --release program.zlp + +# Keep only warn and error +zlup compile --log-level 300 program.zlp +``` + +--- + +## Result Emission + +The `result()` function emits tagged values as program outputs. Unlike logs, results are **never elided** - they're essential for returning data from quantum programs. + +### Basic Usage + +```zlup_fragment +// Emit a measurement result +result("measurement", m); + +// Emit computed values +result("parity", parity_check); +result("syndrome", syndrome_bits); +``` + +### Namespaced Tags + +Use `/` to organize results hierarchically: + +```zlup_fragment +// QEC results +result("qec/syndrome", syndrome); +result("qec/parity", parity); + +// Per-round results +result("round_1/ancilla", ancilla_result); +result("round_2/ancilla", ancilla_result); + +// Nested namespaces +result("experiment/run_5/final_state", state); +``` + +### Supported Value Types + +```zlup_nocheck +result("int_result", 42); // Integers +result("bool_result", true); // Booleans +result("float_result", 3.14); // Floats +result("array_result", [1, 2, 3]); // Arrays +``` + +### Ordering + +Unlike `@emit.sim.*` and `@emit.log.*`, result expressions have **flexible scheduling**. They can "slip down" during compilation - the value just needs to be eventually recorded. This means: + +```zlup_nocheck +h q[0]; +result("before_cx", some_value); // Could be reordered +cx (q[0], q[1]); +result("after_cx", other_value); +``` + +The compiler may batch or reorder result emissions as long as data dependencies are respected. This allows optimizations that wouldn't be possible with strict ordering. + +### Comparison with Guppy + +Zlup's `result()` is equivalent to Guppy's `result(tag, value)`: +- Tag must be a compile-time string literal +- Returns tagged (key, value) pairs to the caller +- Essential for extracting data from quantum program execution + +### Entry Function Pattern + +Entry/main functions should return `unit` and use explicit `result()` calls to emit outputs: + +```zlup_nocheck +fn main() -> unit { + q := qalloc(4); + h q[0]; + cx (q[0], q[1]); + m := measure(q); + result("measurements", m); // Emit to runtime +} +``` + +This pattern keeps the return type simple and makes data emission explicit. + +--- + +## Simulator Control + +The `@emit.sim.*` channel sends hints and commands to the simulator. These are **elided when targeting hardware** (`--target hardware`) but active for simulator and emulator targets. + +The sim channel is intentionally kept simple and flexible. Most communication uses `@emit.sim.send(key, value)` which the noise modeling interprets. + +### Noise Control + +Noise is **enabled by default**. Use these convenience functions to toggle: + +```zlup_nocheck +@emit.sim.noise_disable(); // Turn off noise +@emit.sim.noise_enable(); // Turn on noise (default state) +``` + +### Generic Message Channel + +Use `@emit.sim.send(key, value)` for all other simulator communication: + +```zlup_nocheck +// Set RNG seed for reproducibility +@emit.sim.send("seed", 12345); + +// Configure noise model +@emit.sim.send("noise_model", "depolarizing"); +@emit.sim.send("noise_rate", 0.001); + +// Create checkpoints +@emit.sim.send("checkpoint", "before_correction"); + +// Any custom key-value pairs the simulator understands +@emit.sim.send("custom_param", some_value); +``` + +### Full Example + +```zlup_nocheck +pub fn main() -> unit { + // Set up reproducible simulation + @emit.sim.send("seed", 42); + @emit.sim.send("noise_model", "depolarizing"); + @emit.sim.send("noise_rate", 0.001); + + // Allocate and prepare + q := qalloc(3); + @emit.sim.send("checkpoint", "initial"); + + // Apply gates (noise enabled by default) + h q[0]; + cx (q[0], q[1]); + cx (q[0], q[2]); + + @emit.sim.send("checkpoint", "after_encoding"); + + // Disable noise for measurement + @emit.sim.noise_disable(); + + // Measure + syndrome := mz(u8) q[1..3]; + + result("syndrome", syndrome); + return; +} +``` + +### SLR Output + +Both `result(key, value)` and `@emit.sim.send(key, value)` generate a unified `SendStmt` in SLR: + +```json +{"type": "SendStmt", "channel": "result", "key": "counts", "value": {...}} +{"type": "SendStmt", "channel": "sim", "key": "seed", "value": 42} +{"type": "SendStmt", "channel": "sim", "key": "noise_enable", "value": null} +``` + +The `channel` field distinguishes them for downstream handling (PECOS, etc.). + +### Ordering Semantics + +The three channels have different ordering/scheduling behavior: + +| Channel | Scheduling | Elision Behavior | +|---------|------------|------------------| +| `result(key, value)` | Flexible | Never elided - program output | +| `@emit.sim.*` | Barrier | Elided for hardware, but preserves ordering | +| `@emit.log.*` | Flexible | Can be fully elided in release mode | + +**Result expressions** can "slip" during optimization - they just need to eventually record the value. They have data dependencies on their value but can be reordered relative to other operations. + +**Simulator commands** (`@emit.sim.*`) act as synchronization points. `@emit.sim.noise_disable()` must happen *before* the operations it protects. When compiled for hardware targets, sim commands are elided but could optionally emit a barrier to preserve the same scheduling behavior as on simulator (currently they are completely elided). + +**Log expressions** (`@emit.log.*`) can be completely elided in release mode. Unlike sim commands, logs are purely for debugging - when elided, no semantic difference should exist. This allows optimizations to move operations across where logs used to be. + +**Current implementation:** +- The SLR codegen processes statements in source order +- `@emit.log.*` with elision returns no statement - complete removal, allows optimizations +- `@emit.sim.*` for hardware emits a barrier by default to preserve ordering +- Use `--elide-sim` flag for complete removal (max optimization, no ordering guarantee) +- Downstream tools (PECOS, hardware compilers) may perform their own reordering + +**SimMode options:** +| Mode | Behavior | Use case | +|------|----------|----------| +| `Emit` | Output actual SimStmt | Simulator target | +| `Barrier` | Output scoped barrier | Hardware default - preserves ordering | +| `Elide` | Complete removal | `--elide-sim` flag - max optimization | + +The barrier is scoped to allocators visible in the current scope, not a global barrier. This means `@emit.sim.*` commands only synchronize the qubits that are actually accessible at that point in the program. + +### Target-Dependent Behavior + +| Command | `--target simulator` | `--target hardware` | `--target hardware --elide-sim` | +|---------|---------------------|---------------------|--------------------------------| +| `@emit.sim.noise_enable()` | SimStmt | Barrier (no-op) | Elided | +| `@emit.sim.noise_disable()` | SimStmt | Barrier (no-op) | Elided | +| `@emit.sim.send(...)` | SimStmt | Barrier (no-op) | Elided | + +By default, hardware targets preserve ordering with barriers. Use `--elide-sim` for complete removal. + +--- + +## Compilation Targets + +Zlup separates **what** you're compiling for (target) from **how** you serialize (format). + +### Execution Targets + +```bash +# Simulator (default): full debug, relaxed constraints +zlup compile program.zlp --target simulator + +# Hardware: strict constraints, simulation artifacts removed +zlup compile program.zlp --target hardware + +# Emulator: hardware-like constraints with visibility +zlup compile program.zlp --target emulator +``` + +### Output Formats + +```bash +# SLR-AST JSON (default, for Python/PECOS) +zlup compile program.zlp --format slr + +# PHIR JSON (PECOS simulator) +zlup compile program.zlp --format phir-json + +# OpenQASM 2.0 +zlup compile program.zlp --format qasm +``` + +### Build Modes + +```bash +# Debug (default): all logs, permissive +zlup compile program.zlp --mode debug + +# Release: optimized, logs elided, strict +zlup compile program.zlp --mode release +``` + +### Combined Examples + +```bash +# Development workflow +zlup compile program.zlp # simulator + slr + debug + +# Production for hardware +zlup compile program.zlp --target hardware --mode release + +# Simulation with QASM output +zlup compile program.zlp --target simulator --format qasm + +# Full control +zlup compile program.zlp \ + --target hardware \ + --format slr \ + --mode release \ + --strict true \ + --log-level 400 +``` + +### Effective Settings by Target + Mode + +| Target | Mode | Strict | Log Elision | +|--------|------|--------|-------------| +| simulator | debug | No | None (all logs) | +| simulator | release | Yes | 100+ (debug+) | +| hardware | debug | Yes | 300+ (warn+) | +| hardware | release | Yes | 300+ (warn+) | +| emulator | debug | Yes | 200+ (info+) | +| emulator | release | Yes | 200+ (info+) | + +--- + +## Reserved Keywords + +``` +and break catch comptime continue +defer else enum errdefer error +false fault fn for if +in inline log mut none +not or orelse packed pub +return Self set struct switch +test tick true try type +undefined union unit unsafe +``` + +--- + +## Grammar Summary + +``` +program = declaration* +declaration = binding | fn_decl | struct_decl | enum_decl | union_decl | error_set | fault_set +binding = "pub"? "mut"? identifier (":" type)? "=" expr ";" +fn_decl = "pub"? "inline"? "fn" name "(" params ")" ("->" type)? block +statement = binding | assignment | if | for | switch | tick | return | break | continue | defer | block | expr ";" +expr = binary_expr | unary_expr | primary_expr +``` diff --git a/exp/zlup/docs/tutorial-error-handling.md b/exp/zlup/docs/tutorial-error-handling.md new file mode 100644 index 000000000..187b2a67a --- /dev/null +++ b/exp/zlup/docs/tutorial-error-handling.md @@ -0,0 +1,666 @@ +# Error Handling in Zlup: A Practical Guide + +This tutorial covers Zlup's error handling system, designed specifically for quantum error +correction workflows. You'll learn the difference between faults and errors, how to collect +quantum faults while stopping on classical errors, and how to write robust QEC code. + +## Table of Contents + +- [The Faults vs Errors Distinction](#the-faults-vs-errors-distinction) +- [Error Sets and Fault Sets](#error-sets-and-fault-sets) +- [Error Union Syntax](#error-union-syntax) +- [Handling Errors with catch](#handling-errors-with-catch) +- [Try Blocks: Collect vs Propagate](#try-blocks-collect-vs-propagate) +- [Try Functions](#try-functions) +- [The Explicit Handling Philosophy](#the-explicit-handling-philosophy) +- [Practical QEC Examples](#practical-qec-examples) +- [Summary](#summary) + +--- + +## The Faults vs Errors Distinction + +Zlup distinguishes between **faults** and **errors** based on where they originate and how +we typically want to handle them: + +| Aspect | Fault | Error | +|--------|-------|-------| +| **Origin** | Quantum hardware (physical layer) | Classical logic (software layer) | +| **Nature** | Expected imperfection | Unexpected problem | +| **Handling** | Collect for later analysis | Stop execution immediately | +| **Keyword** | `fault` | `error` | + +### Why This Matters + +**Faults happen at the physical layer.** The quantum hardware did something imperfect: +a gate didn't apply cleanly, a qubit leaked to a non-computational state, a measurement +was noisy. These are *expected* - QEC is designed to handle a certain fault rate. + +```zlup_nocheck +// A QEC round might see 50 faults - that's normal! +// Stopping on every fault would make error correction impossible. +faults, syndrome := syndrome_round(q); +if faults.len < threshold { + // Still correctable - this is what QEC is for + apply_correction(decode(syndrome), q); +} +``` + +**Errors happen at the logical layer.** Something in the classical algorithm went wrong: +the decoder couldn't find a valid correction, a file wasn't found, an invalid state was +reached. These indicate bugs or unrecoverable situations. + +```zlup_nocheck +// If the decoder says "I can't figure out a correction" - that's an error +correction := decode(syndrome) catch |err| { + log("Decoder failed: {}", err); + return; // Can't continue +}; +``` + +**Mental model:** +- Faults = "expected badness we're designed to handle" +- Errors = "unexpected badness that means something is wrong" + +--- + +## Error Sets and Fault Sets + +Define your own error and fault types using the `error` and `fault` keywords: + +### Classical Error Sets + +```zlup +// Define an error set for decoder failures +DecodeError := error { + SyndromeAmbiguous, + WeightTooHigh, + NoValidCorrection, +}; + +// Error sets for I/O operations +IoError := error { + FileNotFound, + PermissionDenied, + ConnectionLost, +}; + +// Create error values +err := error.SyndromeAmbiguous; +``` + +### Quantum Fault Sets + +```zlup +// Define a fault set for quantum hardware faults +QuantumFault := fault { + Leakage, // Qubit leaked to non-computational state + QubitLoss, // Qubit physically lost + GateFailure, // Gate didn't apply correctly + MeasurementError, // Measurement gave wrong result +}; + +// Create fault values +f := fault.Leakage; +``` + +### When to Use Each + +| Situation | Use | +|-----------|-----| +| Decoder can't find correction | `error` | +| File I/O failed | `error` | +| Invalid function argument | `error` | +| Gate applied with noise | `fault` | +| Qubit leaked to |2⟩ | `fault` | +| Measurement bit flip | `fault` | + +--- + +## Error Union Syntax + +Error unions express that a function can either return a value or an error/fault. + +### Basic Error Union: `E!T` + +The `E!T` syntax means "either error type E, or value type T": + +```zlup_nocheck +// Function that might fail +fn divide(a: f64, b: f64) -> DivError!f64 { + if b == 0.0 { + return error.DivisionByZero; + } + return a / b; +} + +// Multiple error types +fn read_config(path: []const u8) -> IoError!Config { + if !file_exists(path) { + return error.FileNotFound; + } + // ... parse and return config + return config; +} +``` + +### Collected Faults: `[]E!T` + +For QEC patterns, you often want to collect all faults that occurred, plus either +an error or the final value: + +```zlup_nocheck +// This function collects quantum faults, might return a classical error +fn qec_round(q: []qubit) try -> []QuantumFault!Syndrome { + // Faults collected, execution continues + cx (q[0], q[1]); // Might fault + cx (q[1], q[2]); // Might fault, still runs + + syndrome := mz([3]u1) [q[3], q[4], q[5]]; + + // Classical error stops execution + correction := decode(syndrome); // If this errors, we stop + + return syndrome; +} + +// Caller receives both faults and result +faults, result := qec_round(q); +``` + +--- + +## Handling Errors with catch + +The `catch` keyword handles errors when they occur: + +### Basic catch + +```zlup_nocheck +// Provide a default value +result := divide(10.0, x) catch 0.0; + +// Handle with a block +result := divide(10.0, x) catch |err| { + log("Division failed: {}", err); + return 0.0; +}; + +// Transform the error +result := read_config("settings.json") catch |err| { + log("Config load failed, using defaults"); + return Config.default(); +}; +``` + +### catch with Error Inspection + +```zlup_nocheck +fn load_or_create(path: []const u8) -> Config { + config := read_config(path) catch |err| switch (err) { + error.FileNotFound => { + // File doesn't exist, create default + return Config.default(); + }, + error.PermissionDenied => { + log("Cannot read {}: permission denied", path); + abort(); + }, + else => { + log("Unexpected error: {}", err); + abort(); + }, + }; + return config; +} +``` + +### Unwrapping with `.!` + +When you're certain the value isn't an error, use `.!` to unwrap: + +```zlup_nocheck +// Only use when you know it will succeed +result := divide(10.0, 2.0).!; // We know 2.0 != 0 + +// Better: use catch for safety +result := divide(10.0, x) catch |_| abort(); +``` + +--- + +## Try Blocks: Collect vs Propagate + +Zlup provides two modes for handling errors within a scope: + +### `try!` - Stop on First Error/Fault (Strict Mode) + +The `try!` block stops immediately when any fault or error occurs: + +```zlup_nocheck +fn strict_preparation(q: []qubit) -> QuantumFault!unit { + try! { + pz q; // If this faults, stop immediately + h q[0]; // Only runs if pz succeeded + cx (q[0], q[1]); // Only runs if h succeeded + } catch |fault| { + log("Preparation failed: {}", fault); + return fault; + } + return; +} +``` + +Use `try!` when: +- Running calibration sequences where any fault invalidates results +- Debugging to find exactly where faults occur +- Operations must succeed completely or not at all + +### `try` - Collect Faults, Stop on Errors (QEC Mode) + +The `try` block collects quantum faults but stops on classical errors: + +```zlup_nocheck +fn qec_syndrome_round(q: []qubit) -> ([]QuantumFault, DecodeError!Syndrome) { + faults, result := try { + // Quantum operations - faults collected, continues + cx (q[0], q[3]); // Fault? Recorded, keep going + cx (q[1], q[3]); // Fault? Recorded, keep going + cx (q[2], q[3]); // Fault? Recorded, keep going + + syndrome := mz([4]u1) [q[3], q[4], q[5], q[6]]; + + // Classical operation - error stops execution + correction := decode(syndrome); // Error? Stop here + + apply_correction(correction, q); + return syndrome; + }; + + // Handle the result + result catch |err| { + log("Decode failed after {} faults: {}", faults.len, err); + return; + }; + + // Success - might still have faults + if faults.len > 0 { + log("Round completed with {} faults", faults.len); + } + return faults, result; +} +``` + +Use `try` when: +- Running QEC rounds where some faults are expected +- Collecting fault statistics for analysis +- Operations should continue despite hardware imperfections + +### Behavior Summary + +| Mode | Quantum Fault | Classical Error | Return Type | +|------|---------------|-----------------|-------------| +| `try!` | **Stops immediately** | **Stops immediately** | `E!T` | +| `try` | Collected, continues | Stops, returns collected faults | `([]Fault, E!T)` | + +--- + +## Try Functions + +Functions can be declared with `try` or `try!` to indicate their error handling mode: + +### `try!` Functions + +```zlup_nocheck +// Strict mode: any fault/error stops execution +fn calibrate_qubit(q: qubit) try! -> CalibrationFault!CalibrationData { + pz q; + h q; + + // Repeated measurements for statistics + for i in 0..100 { + m := mz(u1) q; + record(m); + pz q; + h q; + } + + return analyze_calibration(); +} + +// Caller handles single fault/error +data := calibrate_qubit(q[0]) catch |fault| { + log("Calibration failed: {}", fault); + return; +}; +``` + +### `try` Functions + +```zlup_nocheck +// QEC mode: faults collected, errors stop +fn run_qec_cycle(code: *SurfaceCode) try -> []QuantumFault!unit { + // All operations in this function collect faults + code.syndrome_round(); + code.decode_and_correct(); + return; +} + +// Caller receives faults and result +faults, result := run_qec_cycle(&code); + +result catch |err| { + log("QEC cycle failed: {}", err); + log("Faults before failure: {}", faults); + return; +}; + +// Success path +if faults.len > warning_threshold { + log("Warning: {} faults in cycle", faults.len); +} +``` + +--- + +## The Explicit Handling Philosophy + +Zlup intentionally does **not** have a `?` operator like Rust. This is a deliberate design +choice for quantum computing contexts. + +### Why No `?` Operator? + +Rust's `?` operator makes error propagation easy - perhaps too easy: + +```rust +// Rust: ? makes it easy to just bubble errors up without thought +fn do_something() -> Result { + let x = step1()?; // Just propagate + let y = step2()?; // Just propagate + let z = step3()?; // Just propagate + Ok(z) +} +``` + +While explicit, this pattern becomes so automatic that developers stop thinking about +error handling. Errors bubble up, but nobody along the way considered what to do about them. + +### Zlup's Approach: Deliberate Handling + +In QEC, errors and faults carry crucial diagnostic information. Mechanical propagation +loses context and makes debugging difficult: + +```zlup_nocheck +// INVALID: ignoring the return value +qec_round(q); // Compile error: unhandled faults/errors + +// ENCOURAGED: deliberate handling with context +faults, result := qec_round(q); +result catch |err| { + log("QEC round failed at step {}: {}", step, err); + log("Faults before failure: {}", faults); + log("Syndrome state: {}", last_syndrome); + return err; // Propagate with added context +}; + +// VALID: explicitly discard if truly not needed +_ = qec_round(q); // Explicit discard - you meant to ignore it +``` + +### Why This Matters for Quantum Computing + +In classical computing, a propagated error eventually reaches a handler somewhere. +In quantum computing, by the time an error surfaces, the quantum state may be +irretrievably corrupted. Understanding *where* and *why* things went wrong is essential for: + +- Debugging QEC implementations +- Tuning error thresholds +- Identifying systematic hardware issues +- Post-mortem analysis of failed computations + +Silent or mechanical error propagation loses this crucial information. + +--- + +## Practical QEC Examples + +### Example 1: Basic Syndrome Extraction + +```zlup_nocheck +QuantumFault := fault { Leakage, GateError, MeasurementError }; +QecError := error { DecodeFailed, TooManyFaults }; + +fn extract_syndrome(data: []qubit, ancilla: []qubit) try -> []QuantumFault![]u1 { + // Prepare ancilla + pz ancilla; + h ancilla; + + // Stabilizer measurements (faults collected) + for i in 0..ancilla.len { + cx (data[i * 2], ancilla[i]); + cx (data[i * 2 + 1], ancilla[i]); + } + + h ancilla; + + // Measure ancilla + syndrome := mz([4]u1) ancilla; + + return syndrome; +} + +pub fn main() -> unit { + q := qalloc(8); + pz q; + + alias data := q[0..4]; + alias ancilla := q[4..8]; + + // Run syndrome extraction + faults, syndrome_result := extract_syndrome(data, ancilla); + + syndrome := syndrome_result catch |err| { + log("Syndrome extraction failed: {}", err); + return; + }; + + log("Syndrome: {}, Faults: {}", syndrome, faults.len); + result("syndrome", syndrome); + result("fault_count", faults.len); + + return; +} +``` + +### Example 2: Full QEC Round with Threshold + +```zlup_nocheck +QuantumFault := fault { Leakage, QubitLoss, GateFailure }; +DecodeError := error { SyndromeAmbiguous, WeightTooHigh }; + +fn qec_round(code: *SurfaceCode, max_faults: usize) try -> []QuantumFault!DecodeError!unit { + // Extract syndrome (collects faults) + syndrome := code.measure_stabilizers(); + + // Check if too many faults occurred + // Note: we're inside a try block, so faults are being collected + // We can inspect them at any point + + // Decode (classical - error stops execution) + correction := decode(syndrome); + + // Apply correction + code.apply_correction(correction); + + return; +} + +pub fn main() -> unit { + mut base := qalloc(100); + mut code := SurfaceCode.init(&base, 3); + + code.prepare_logical_zero(); + + // Run multiple QEC rounds + mut total_faults: usize = 0; + + for round in 0..1000 { + faults, result := qec_round(&code, 10); + + total_faults += faults.len; + + result catch |err| { + log("Round {} failed: {}", round, err); + log("Total faults so far: {}", total_faults); + break; + }; + + // Log periodic status + if round % 100 == 0 { + log("Round {}: {} faults this round, {} total", round, faults.len, total_faults); + } + } + + // Final measurement + logical := code.measure_logical(); + result("logical_result", logical); + result("total_faults", total_faults); + + return; +} +``` + +### Example 3: Promoting Faults to Errors + +Sometimes accumulated faults cross a threshold where they should become a logical error: + +```zlup_nocheck +fn qec_round_with_threshold( + q: []qubit, + max_correctable: usize +) try -> []QuantumFault!QecError!Syndrome { + // Run the circuit, collecting faults + faults, syndrome := run_stabilizers(q); + + // Too many faults? Promote to a classical error + // This is a classical decision, so it's an error, not a fault + if faults.len > max_correctable { + return error.TooManyFaults; + } + + // Faults within tolerance - continue + return syndrome; +} + +// Caller receives both the faults AND the error/result +faults, result := qec_round_with_threshold(q, 5); + +result catch |err| { + // err might be TooManyFaults - we still have access to `faults` + log("QEC failed with {} faults: {}", faults.len, err); + return; +}; + +// Success - but we still know how many faults occurred +syndrome := result.!; +if faults.len > 0 { + log("Succeeded with {} faults", faults.len); +} +``` + +### Example 4: Rich Fault Context + +Faults carry context information that can be inspected: + +```zlup_nocheck +fn analyze_faults(faults: []QuantumFault) -> unit { + mut leakage_count: usize = 0; + mut gate_failures: usize = 0; + + for fault in faults { + switch (fault) { + fault.Leakage => |ctx| { + log("Leakage in {} on qubit {}", ctx.gate, ctx.qubit); + leakage_count += 1; + }, + fault.QubitLoss => |ctx| { + log("Lost qubit {} during {}", ctx.qubit, ctx.gate); + flag_qubit_lost(ctx.qubit); + }, + fault.GateFailure => |ctx| { + log("Gate {} failed on qubits {:?}", ctx.gate, ctx.qubits); + gate_failures += 1; + }, + else => { + log("Unknown fault: {}", fault); + }, + } + } + + result("leakage_events", leakage_count); + result("gate_failures", gate_failures); + + return; +} +``` + +--- + +## Summary + +### Key Concepts + +| Concept | Description | +|---------|-------------| +| **Fault** | Physical layer issue (expected, collected) | +| **Error** | Logical layer issue (unexpected, stops execution) | +| **`E!T`** | Error union: either error E or value T | +| **`[]E!T`** | Collected faults plus error union | +| **`try!`** | Stop on first fault/error (strict) | +| **`try`** | Collect faults, stop on errors (QEC) | +| **`catch`** | Handle errors with default or block | +| **`.!`** | Unwrap (when certain no error) | + +### Best Practices + +1. **Use `fault` for hardware issues**, `error` for software issues +2. **Use `try` blocks for QEC rounds** where faults are expected +3. **Use `try!` blocks for calibration** where any fault invalidates results +4. **Always handle or explicitly discard** return values +5. **Add context when propagating errors** - don't just bubble up +6. **Inspect fault context** to diagnose hardware issues +7. **Set thresholds** to promote excessive faults to errors + +### Quick Reference + +```zlup_nocheck +// Define fault and error sets +QuantumFault := fault { Leakage, QubitLoss }; +DecodeError := error { Failed, Ambiguous }; + +// Function that collects faults +fn qec_op(q: []qubit) try -> []QuantumFault!DecodeError!T { ... } + +// Function that stops on first fault +fn strict_op(q: []qubit) try! -> QuantumFault!T { ... } + +// Handle with catch +result := may_fail() catch default_value; +result := may_fail() catch |err| { handle(err); return; }; + +// Collect faults from try function +faults, result := qec_op(q); + +// Try block (collect mode) +faults, result := try { + // quantum ops - faults collected + // classical ops - errors stop +}; + +// Try! block (strict mode) +try! { + // any fault or error stops immediately +} catch |err| { + handle(err); +}; +``` diff --git a/exp/zlup/docs/tutorial.md b/exp/zlup/docs/tutorial.md new file mode 100644 index 000000000..650b8fe04 --- /dev/null +++ b/exp/zlup/docs/tutorial.md @@ -0,0 +1,492 @@ +# Zlup Tutorial: Getting Started + +This tutorial will guide you through writing your first Zlup quantum programs. By the end, you'll understand the core concepts: allocators, gates, measurement, and control flow. + +## What is Zlup? + +Zlup is the **low-level complement to Guppy** in PECOS. While Guppy provides a high-level, Pythonic experience for QEC researchers, Zlup is designed for: + +- **Quantum orchestration**: Gate sequences, syndrome extraction, correction application +- **Rust integration**: Calling decoders and simulation backends via FFI +- **Reliable, production-grade code** following NASA Power of 10 principles +- **Simulation infrastructure**: Noise modeling, error injection, state tracking + +Complex classical algorithms (MWPM decoders, etc.) belong in **Rust**—Zlup handles the quantum side and calls Rust for heavy computation. + +If you're a QEC researcher who prefers Python, **use Guppy**. Zlup is for the systems layer connecting quantum operations to classical backends. + +## Prerequisites + +- Zlup compiler installed (`cargo install --path .` from the zlup directory) +- Basic understanding of quantum computing concepts (qubits, gates, measurement) + +## Your First Program: Hello Quantum + +Create a file called `hello.zlp`: + +```zlup +/// My first Zlup program - creates a superposition state +pub fn main() -> unit { + // Allocate a single qubit + q := qalloc(1); + + // Prepare (reset) the qubit to |0⟩ + pz q; + + // Apply Hadamard gate to create superposition + h q[0]; + + // Measure the qubit + result: u1 = mz(u1) q[0]; + + return; +} +``` + +Compile it: + +```bash +zlup compile hello.zlp -o hello.json +``` + +Let's break down what each line does. + +## Allocators: Managing Qubits + +In Zlup, qubits are managed through **allocators**. This explicit resource management (inspired by Zig) ensures you always know where your qubits come from. + +```zlup_fragment +// Allocate 4 qubits +q := qalloc(4); + +// Access individual qubits by index +h q[0]; // First qubit +h q[1]; // Second qubit +h q[3]; // Fourth qubit (last) +``` + +The allocator capacity is fixed at compile time - you can't dynamically grow it. This is intentional: bounded resources make programs predictable and analyzable. + +### Child Allocators + +For complex algorithms, you can partition allocators: + +```zlup_fragment +// Main allocator with 10 qubits +mut main := qalloc(10); + +// Create child allocators from it +data := main.child(4); // First 4 qubits for data +ancilla := main.child(6); // Remaining 6 for ancillas +``` + +Note: The parent must be declared `mut` to create children. + +## Preparing Qubits + +Before using qubits, prepare (reset) them to the |0⟩ state with `pz`: + +```zlup_fragment +q := qalloc(4); + +// Prepare all qubits at once +pz q; + +// Or prepare specific qubits +pz q[0]; +pz {q[1], q[2]}; // Batch prepare +``` + +## Gates: Quantum Operations + +### Single-Qubit Gates + +```zlup_fragment +// Pauli gates +x q[0]; // X (NOT) gate +y q[0]; // Y gate +z q[0]; // Z gate + +// Hadamard +h q[0]; // Creates superposition + +// T gates (fourth root of Z) +t q[0]; // T gate +tdg q[0]; // T-dagger (inverse of T) + +// Square root gates (sz is the S gate) +sx q[0]; // sqrt(X) +sy q[0]; // sqrt(Y) +sz q[0]; // sqrt(Z) - this is the S gate +sxdg q[0]; // sqrt(X) dagger +sydg q[0]; // sqrt(Y) dagger +szdg q[0]; // sqrt(Z) dagger - this is S-dagger +``` + +### Rotation Gates (Parameterized) + +Rotation gates require an angle with explicit units: + +```zlup_nocheck +std := @import("std"); + +// Preferred: use turns (native unit) +rx(1/8 turns) q[0]; // Rotate by 1/8 turn (pi/4 rad) around X +ry(1/4 turns) q[0]; // Rotate by 1/4 turn (pi/2 rad) around Y +rz(1/2 turns) q[0]; // Rotate by 1/2 turn (pi rad) around Z + +// Or use a64 constants +rz(std.a64.t_angle turns) q[0]; // T-gate (1/8 turn) + +// Or use radians with f64 constants +rx(std.f64.pi/4 rad) q[0]; // Same as 1/8 turns +``` + +### Two-Qubit Gates + +Two-qubit gates use tuple syntax for (control, target): + +```zlup_fragment +// CNOT (controlled-X) +cx (q[0], q[1]); // q[0] controls, q[1] is target + +// Other controlled gates +cy (q[0], q[1]); // Controlled-Y +cz (q[0], q[1]); // Controlled-Z +ch (q[0], q[1]); // Controlled-Hadamard + +// Swap gates +swap (q[0], q[1]); +iswap (q[0], q[1]); + +// Ising gates +sxx (q[0], q[1]); // sqrt(XX) +syy (q[0], q[1]); // sqrt(YY) +szz (q[0], q[1]); // sqrt(ZZ) + +// Parameterized two-qubit +rzz(1/8 turns) (q[0], q[1]); +``` + +### Batch Operations + +Apply the same gate to multiple qubits in parallel using set syntax: + +```zlup_fragment +// Apply H to multiple qubits (order doesn't matter) +h {q[0], q[1], q[2]}; + +// Batch two-qubit gates +cx {(q[0], q[1]), (q[2], q[3])}; + +// Batch rotation +rx(1/4 turns) {q[0], q[1], q[2]}; +``` + +## Measurement + +Measurement extracts classical information from qubits: + +```zlup_fragment +// Measure single qubit into a u1 (single bit) +r: u1 = mz(u1) q[0]; + +// Measure multiple qubits into an array +results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + +// Pack measurements into a byte +syndrome: u8 = mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; +``` + +The `pack` modifier packs bits sequentially into the target type. + +## Emitting Results + +To output values from your quantum program, use `result()`: + +```zlup_fragment +// Emit a single measurement +result("final_bit", r); + +// Emit array of measurements +result("syndrome", results); + +// Use namespaced tags for organization +result("qec/round_1/syndrome", syndrome); +``` + +Results are collected by the quantum runtime and never elided. Entry/main functions +return `unit` and use explicit `result()` calls rather than returning values. + +## Creating a Bell State + +Let's create a Bell state - the simplest entangled state: + +```zlup +/// Creates a Bell state |00⟩ + |11⟩ (unnormalized) +pub fn main() -> unit { + q := qalloc(2); + pz q; + + // Create superposition on first qubit + h q[0]; + + // Entangle with second qubit + cx (q[0], q[1]); + + // Measure both qubits + r0: u1 = mz(u1) q[0]; + r1: u1 = mz(u1) q[1]; + + // Emit results - r0 and r1 will always be correlated: + // both 0 or both 1 + result("qubit_0", r0); + result("qubit_1", r1); + + return; +} +``` + +## Variables and Bindings + +Zlup uses Pascal/Go-style binding syntax: + +```zlup_fragment +// Immutable binding (most common) +x := 42; // Type inferred +y: u32 = 100; // Type explicit + +// Mutable binding +mut count := 0; +count = count + 1; + +// Constants +pi := 3.14159; // Immutable by default +``` + +## Control Flow + +### If Statements + +```zlup_nocheck +// Simple if +if x > 10 { + // do something +} + +// If-else +if condition { + // true branch +} else { + // false branch +} + +// If-else if chain +if x == 0 { + // zero +} else if x < 0 { + // negative +} else { + // positive +} +``` + +### For Loops (Bounded) + +All loops in Zlup must have bounded iteration (NASA Power of 10 rule): + +```zlup_nocheck +// Range loop +for i in 0..10 { + h q[i]; +} + +// Iterating with index +for i, item in items { + // use i and item +} +``` + +### Switch Statements + +```zlup_nocheck +switch (value) { + 0 => { /* handle 0 */ }, + 1, 2 => { /* handle 1 or 2 */ }, + 3..10 => { /* handle range 3-10 */ }, + else => { /* default */ }, +} +``` + +## Functions + +```zlup_nocheck +// Simple function +fn add(a: u32, b: u32) -> u32 { + return a + b; +} + +// Function with no return value +fn apply_gates(q: *Allocator) -> unit { + h q[0]; + return; +} + +// Public function (exported) +pub fn main() -> unit { + result := add(2, 3); + return; +} +``` + +## Working with the Standard Library + +Import and use standard library modules: + +```zlup_nocheck +std := @import("std"); + +pub fn main() -> unit { + // Angle constants from std.a64 + angle := std.a64.t_angle; + + q := qalloc(8); + pz q; + + // Apply rotation (using turns) + rz(angle turns) q[0]; + + // Measure and compute parity + syndrome: u8 = mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; + parity := std.parity_u8(syndrome); + + return; +} +``` + +## Tick Blocks: Parallel Execution + +Group operations that should execute in parallel: + +```zlup_fragment +q := qalloc(4); +pz q; + +// These gates execute in the same time step +tick { + h q[0]; + h q[1]; + h q[2]; + h q[3]; +} + +// Next time step +tick { + cx (q[0], q[1]); + cx (q[2], q[3]); +} +``` + +## Error Handling + +Zlup distinguishes between **faults** (physical layer, quantum hardware) and **errors** +(logical layer, classical software). This distinction is crucial for QEC: + +- **Faults** are expected hardware imperfections - collected and analyzed +- **Errors** are unexpected software problems - stop execution immediately + +### Quick Example + +```zlup_nocheck +// Classical errors stop execution +fn divide(a: u32, b: u32) -> DivError!u32 { + if b == 0 { + return error.DivisionByZero; + } + return a / b; +} + +// Handle errors with catch +result := divide(10, 2) catch 0; // Default to 0 on error + +// Quantum faults are collected (QEC pattern) +QuantumFault := fault { Leakage, QubitLoss }; + +fn qec_round(q: []qubit) try -> []QuantumFault!Syndrome { + cx (q[0], q[1]); // Fault? Recorded, continues + cx (q[1], q[2]); // Fault? Recorded, continues + return mz([2]u1) [q[3], q[4]]; +} + +// Caller receives both faults and result +faults, syndrome := qec_round(q); +``` + +### Two Error Handling Modes + +| Mode | Quantum Fault | Classical Error | +|------|---------------|-----------------| +| `try!` | Stops immediately | Stops immediately | +| `try` | Collected, continues | Stops execution | + +For a comprehensive guide to error handling including practical QEC examples, fault +promotion, and the explicit handling philosophy, see the +**[Error Handling Tutorial](tutorial-error-handling.md)**. + +## Complete Example: GHZ State + +A GHZ state is a maximally entangled state of N qubits: + +```zlup_nocheck +/// Creates a 4-qubit GHZ state: |0000⟩ + |1111⟩ +pub fn main() -> unit { + std := @import("std"); + + q := qalloc(4); + pz q; + + // Put first qubit in superposition + h q[0]; + + // Entangle each subsequent qubit + for i in 1..4 { + cx (q[0], q[i]); + } + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + // Emit results - all will be the same: either all 0 or all 1 + result("measurements", results); + + return; +} +``` + +## Next Steps + +- Read the [Standard Library Reference](stdlib.md) for available functions +- Check out the [examples](../examples/) directory for more programs +- See [design.md](design.md) for language design rationale +- Try `zlup eval "2 + 2"` for quick expression testing + +## Quick Reference + +| Concept | Syntax | +|---------|--------| +| Allocate qubits | `q := qalloc(N);` | +| Prepare qubits | `pz q;` | +| Single gate | `h q[0];` | +| Rotation gate | `rz(angle) q[0];` | +| Two-qubit gate | `cx (q[0], q[1]);` | +| Batch gates | `h {q[0], q[1]};` | +| Measure one | `r: u1 = mz(u1) q[0];` | +| Measure many | `r: [N]u1 = mz([N]u1) [...];` | +| Pack measure | `r: u8 = mz(pack u8) [...];` | +| Emit result | `result("tag", value);` | +| Immutable var | `x := value;` | +| Mutable var | `mut x := value;` | +| For loop | `for i in 0..N { }` | +| If statement | `if cond { } else { }` | +| Function | `fn name(args) -> T { }` | +| Import | `std := @import("std");` | diff --git a/exp/zlup/editors/jetbrains-zlup/.gitignore b/exp/zlup/editors/jetbrains-zlup/.gitignore new file mode 100644 index 000000000..a7be4dd85 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/.gitignore @@ -0,0 +1,15 @@ +# Gradle +.gradle/ +build/ +gradle/wrapper/gradle-wrapper.jar + +# IntelliJ Platform +.intellijPlatform/ + +# IDE +.idea/ +*.iml + +# OS +.DS_Store +Thumbs.db diff --git a/exp/zlup/editors/jetbrains-zlup/README.md b/exp/zlup/editors/jetbrains-zlup/README.md new file mode 100644 index 000000000..9ac84b523 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/README.md @@ -0,0 +1,60 @@ +# Zlup JetBrains Plugin + +IntelliJ IDEA / JetBrains IDE plugin for the Zlup quantum programming language. + +## Features + +- Syntax highlighting for `.zlp` files +- Comment toggling (`Ctrl+/`) +- Brace matching + +## LSP Support + +For full LSP features (diagnostics, completion, hover), install the **LSP4IJ** plugin from the JetBrains Marketplace, then configure it to use the `zlups` language server. + +### LSP4IJ Configuration + +1. Install LSP4IJ from Marketplace +2. Go to **Settings > Languages & Frameworks > Language Servers** +3. Click **+** to add a new server: + - **Name**: `Zlups` + - **Command**: `/path/to/PECOS-alt/target/release/zlups` + - **File patterns**: `*.zlp` + +## Building the Plugin + +### Requirements +- JDK 17 or later + +### Build Commands + +```bash +cd editors/jetbrains-zlup +./gradlew buildPlugin +``` + +The plugin ZIP will be in `build/distributions/`. + +## Installation + +### From pre-built ZIP + +1. In your JetBrains IDE: **Settings > Plugins > Gear icon > Install Plugin from Disk...** +2. Select `jetbrains-zlup-0.1.0.zip` +3. Restart the IDE + +### Development mode + +Run the plugin in a sandbox IDE: +```bash +./gradlew runIde +``` + +## Building zlups (LSP server) + +```bash +cd /path/to/PECOS-alt/exp/zlup +cargo build --features lsp --release +``` + +The binary will be at `target/release/zlups`. diff --git a/exp/zlup/editors/jetbrains-zlup/build.gradle.kts b/exp/zlup/editors/jetbrains-zlup/build.gradle.kts new file mode 100644 index 000000000..d8ed26dd0 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("java") + id("org.jetbrains.kotlin.jvm") version "1.9.21" + id("org.jetbrains.intellij.platform") version "2.2.1" +} + +group = "com.zlup" +version = "0.1.0" + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdeaCommunity("2024.1") + pluginVerifier() + zipSigner() + } +} + +kotlin { + jvmToolchain(17) +} + +tasks { + patchPluginXml { + sinceBuild.set("241") + untilBuild.set("253.*") + } + + signPlugin { + certificateChain.set(System.getenv("CERTIFICATE_CHAIN")) + privateKey.set(System.getenv("PRIVATE_KEY")) + password.set(System.getenv("PRIVATE_KEY_PASSWORD")) + } + + publishPlugin { + token.set(System.getenv("PUBLISH_TOKEN")) + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/gradle.properties b/exp/zlup/editors/jetbrains-zlup/gradle.properties new file mode 100644 index 000000000..8f107b3bc --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2048m +kotlin.stdlib.default.dependency=false diff --git a/exp/zlup/editors/jetbrains-zlup/gradle/wrapper/gradle-wrapper.properties b/exp/zlup/editors/jetbrains-zlup/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1af9e0930 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/exp/zlup/editors/jetbrains-zlup/gradlew b/exp/zlup/editors/jetbrains-zlup/gradlew new file mode 100755 index 000000000..7540f8691 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/gradlew @@ -0,0 +1,152 @@ +#!/bin/sh + +# +# Gradle wrapper script +# + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Download gradle-wrapper.jar if it doesn't exist +if [ ! -f "$CLASSPATH" ]; then + echo "Downloading gradle-wrapper.jar..." + mkdir -p "$APP_HOME/gradle/wrapper" + if command -v curl > /dev/null 2>&1; then + curl -sL -o "$CLASSPATH" "https://raw.githubusercontent.com/gradle/gradle/v8.5.0/gradle/wrapper/gradle-wrapper.jar" + elif command -v wget > /dev/null 2>&1; then + wget -q -O "$CLASSPATH" "https://raw.githubusercontent.com/gradle/gradle/v8.5.0/gradle/wrapper/gradle-wrapper.jar" + else + die "ERROR: Please install curl or wget to download the Gradle wrapper" + fi +fi + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in + '' | soft) :;; + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/exp/zlup/editors/jetbrains-zlup/settings.gradle.kts b/exp/zlup/editors/jetbrains-zlup/settings.gradle.kts new file mode 100644 index 000000000..16913a836 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +rootProject.name = "jetbrains-zlup" diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupBraceMatcher.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupBraceMatcher.kt new file mode 100644 index 000000000..dff14c0bc --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupBraceMatcher.kt @@ -0,0 +1,22 @@ +package com.zlup.ide + +import com.intellij.lang.BracePair +import com.intellij.lang.PairedBraceMatcher +import com.intellij.psi.PsiFile +import com.intellij.psi.tree.IElementType + +class ZlupBraceMatcher : PairedBraceMatcher { + companion object { + private val PAIRS = arrayOf( + BracePair(ZlupTokenTypes.LBRACE, ZlupTokenTypes.RBRACE, true), + BracePair(ZlupTokenTypes.LPAREN, ZlupTokenTypes.RPAREN, false), + BracePair(ZlupTokenTypes.LBRACKET, ZlupTokenTypes.RBRACKET, false) + ) + } + + override fun getPairs(): Array = PAIRS + + override fun isPairedBracesAllowedBeforeType(lbraceType: IElementType, contextType: IElementType?): Boolean = true + + override fun getCodeConstructStart(file: PsiFile, openingBraceOffset: Int): Int = openingBraceOffset +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupColorSettingsPage.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupColorSettingsPage.kt new file mode 100644 index 000000000..039c0636a --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupColorSettingsPage.kt @@ -0,0 +1,68 @@ +package com.zlup.ide + +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.fileTypes.SyntaxHighlighter +import com.intellij.openapi.options.colors.AttributesDescriptor +import com.intellij.openapi.options.colors.ColorDescriptor +import com.intellij.openapi.options.colors.ColorSettingsPage +import javax.swing.Icon + +class ZlupColorSettingsPage : ColorSettingsPage { + companion object { + private val DESCRIPTORS = arrayOf( + AttributesDescriptor("Keyword", ZlupSyntaxHighlighter.KEYWORD), + AttributesDescriptor("Type", ZlupSyntaxHighlighter.TYPE), + AttributesDescriptor("Gate", ZlupSyntaxHighlighter.GATE), + AttributesDescriptor("Identifier", ZlupSyntaxHighlighter.IDENTIFIER), + AttributesDescriptor("Number", ZlupSyntaxHighlighter.NUMBER), + AttributesDescriptor("String", ZlupSyntaxHighlighter.STRING), + AttributesDescriptor("Line Comment", ZlupSyntaxHighlighter.LINE_COMMENT), + AttributesDescriptor("Block Comment", ZlupSyntaxHighlighter.BLOCK_COMMENT), + AttributesDescriptor("Operator", ZlupSyntaxHighlighter.OPERATOR), + AttributesDescriptor("Brackets", ZlupSyntaxHighlighter.BRACKETS), + AttributesDescriptor("Braces", ZlupSyntaxHighlighter.BRACES), + AttributesDescriptor("Parentheses", ZlupSyntaxHighlighter.PARENTHESES), + AttributesDescriptor("Comma", ZlupSyntaxHighlighter.COMMA), + AttributesDescriptor("Semicolon", ZlupSyntaxHighlighter.SEMICOLON), + AttributesDescriptor("Dot", ZlupSyntaxHighlighter.DOT), + ) + } + + override fun getIcon(): Icon = ZlupIcons.FILE + + override fun getHighlighter(): SyntaxHighlighter = ZlupSyntaxHighlighter() + + override fun getDemoText(): String = """ +/// Bell state preparation +/// Creates entangled qubit pair +pub fn main() -> unit { + // Allocate 2 qubits + q := qalloc(2); + + /* Prepare and entangle */ + pz q; + h q[0]; + cx (q[0], q[1]); + + // Measure results + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + + if results[0] == 1 { + x q[1]; // Apply correction + } + + return unit; +} + +const PI: f64 = 3.14159265358979; +const NUM_QUBITS: u32 = 4; +""".trimIndent() + + override fun getAdditionalHighlightingTagToDescriptorMap(): Map? = null + + override fun getAttributeDescriptors(): Array = DESCRIPTORS + + override fun getColorDescriptors(): Array = ColorDescriptor.EMPTY_ARRAY + + override fun getDisplayName(): String = "Zlup" +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCommenter.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCommenter.kt new file mode 100644 index 000000000..fe8ab49d6 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCommenter.kt @@ -0,0 +1,11 @@ +package com.zlup.ide + +import com.intellij.lang.Commenter + +class ZlupCommenter : Commenter { + override fun getLineCommentPrefix(): String = "//" + override fun getBlockCommentPrefix(): String = "/*" + override fun getBlockCommentSuffix(): String = "*/" + override fun getCommentedBlockCommentPrefix(): String? = null + override fun getCommentedBlockCommentSuffix(): String? = null +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCompletionContributor.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCompletionContributor.kt new file mode 100644 index 000000000..52f96d371 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCompletionContributor.kt @@ -0,0 +1,174 @@ +package com.zlup.ide + +import com.intellij.codeInsight.completion.* +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.patterns.PlatformPatterns +import com.intellij.util.ProcessingContext +import com.intellij.icons.AllIcons + +class ZlupCompletionContributor : CompletionContributor() { + init { + // Keywords + extend( + CompletionType.BASIC, + PlatformPatterns.psiElement(), + KeywordCompletionProvider() + ) + } +} + +class KeywordCompletionProvider : CompletionProvider() { + companion object { + private val KEYWORDS = listOf( + // Control flow + "if", "else", "while", "for", "return", "break", "continue", + // Declarations + "fn", "pub", "const", "struct", "enum", "union", "error", "type", + // Error handling + "try", "catch", "orelse", "defer", "errdefer", + // Modifiers + "mut", "comptime", "inline", "packed", "extern", + // Logical + "and", "or", "not", + // Literals + "true", "false", "null", "undefined", "unit", + // Other + "in", "tick", "barrier" + ) + + private val TYPES = listOf( + // Integer types + "u1", "u8", "u16", "u32", "u64", "u128", + "i8", "i16", "i32", "i64", "i128", + "usize", "isize", + // Float types + "f16", "f32", "f64", "f128", + // Angle type + "a64", + // Boolean + "bool", + // Quantum types + "qubit", "bit", "qalloc", + // Special + "void", "type", "anytype", "anyerror", "anyfault" + ) + + private val GATES = listOf( + // Single-qubit gates + "h", "x", "y", "z", "s", "sdg", "t", "tdg", + "sx", "sxdg", "sy", "sydg", "sz", "szdg", + "f", "fdg", "f4", "f4dg", + // Rotations + "rx", "ry", "rz", + // Two-qubit gates + "cx", "cy", "cz", "ch", "swap", "iswap", + "sxx", "sxxdg", "syy", "syydg", "szz", "szzdg", "rzz", + // Three-qubit gates + "ccx", + // Measurement/preparation + "mz", "mx", "my", "pz", "px", "py" + ) + + private val BUILTINS = listOf( + "@import", "@sizeof", "@alignof", "@typeOf", + "@intCast", "@floatCast", "@truncate", + "@bitCast", "@ptrCast", + "@min", "@max", "@clamp", + "@sqrt", "@sin", "@cos", "@tan", + "@log", "@log2", "@log10", "@exp", + "@floor", "@ceil", "@round", + "@abs", "@mod", "@divFloor", "@divTrunc" + ) + + private val STD_MODULES = listOf( + "std.f64.pi", "std.f64.tau", "std.f64.e", "std.f64.sqrt2", + "std.a64.quarter_turn", "std.a64.half_turn", "std.a64.t_angle", + "std.math", "std.bits", "std.qec" + ) + } + + override fun addCompletions( + parameters: CompletionParameters, + context: ProcessingContext, + result: CompletionResultSet + ) { + // Add keywords + for (keyword in KEYWORDS) { + result.addElement( + LookupElementBuilder.create(keyword) + .withIcon(AllIcons.Nodes.Favorite) + .withTypeText("keyword") + .bold() + ) + } + + // Add types + for (type in TYPES) { + result.addElement( + LookupElementBuilder.create(type) + .withIcon(AllIcons.Nodes.Class) + .withTypeText("type") + ) + } + + // Add gates + for (gate in GATES) { + result.addElement( + LookupElementBuilder.create(gate) + .withIcon(AllIcons.Nodes.Function) + .withTypeText("gate") + .withTailText(" (quantum gate)") + ) + } + + // Add builtins + for (builtin in BUILTINS) { + result.addElement( + LookupElementBuilder.create(builtin) + .withIcon(AllIcons.Nodes.Method) + .withTypeText("builtin") + ) + } + + // Add common std library items + for (module in STD_MODULES) { + result.addElement( + LookupElementBuilder.create(module) + .withIcon(AllIcons.Nodes.Module) + .withTypeText("std") + ) + } + + // Add common code snippets + result.addElement( + LookupElementBuilder.create("fn main() -> unit {\n \n}") + .withPresentableText("fn main") + .withIcon(AllIcons.Nodes.Function) + .withTypeText("main function") + .withInsertHandler { ctx, _ -> + ctx.editor.caretModel.moveToOffset(ctx.tailOffset - 2) + } + ) + + result.addElement( + LookupElementBuilder.create("q := qalloc()") + .withPresentableText("qalloc") + .withIcon(AllIcons.Nodes.Variable) + .withTypeText("allocate qubits") + ) + + result.addElement( + LookupElementBuilder.create("for i in 0..n {\n \n}") + .withPresentableText("for loop") + .withIcon(AllIcons.Nodes.Favorite) + .withTypeText("for loop") + ) + + result.addElement( + LookupElementBuilder.create("if condition {\n \n} else {\n \n}") + .withPresentableText("if-else") + .withIcon(AllIcons.Nodes.Favorite) + .withTypeText("if-else block") + ) + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFile.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFile.kt new file mode 100644 index 000000000..19d100288 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFile.kt @@ -0,0 +1,10 @@ +package com.zlup.ide + +import com.intellij.extapi.psi.PsiFileBase +import com.intellij.openapi.fileTypes.FileType +import com.intellij.psi.FileViewProvider + +class ZlupFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, ZlupLanguage) { + override fun getFileType(): FileType = ZlupFileType + override fun toString(): String = "Zlup File" +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFileType.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFileType.kt new file mode 100644 index 000000000..e1330b9e8 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFileType.kt @@ -0,0 +1,13 @@ +package com.zlup.ide + +import com.intellij.openapi.fileTypes.LanguageFileType +import javax.swing.Icon + +object ZlupFileType : LanguageFileType(ZlupLanguage) { + override fun getName(): String = "Zlup" + override fun getDescription(): String = "Zlup quantum programming language" + override fun getDefaultExtension(): String = "zlp" + override fun getIcon(): Icon = ZlupIcons.FILE + + const val EXTENSION = "zlp" +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFoldingBuilder.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFoldingBuilder.kt new file mode 100644 index 000000000..200a7a157 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFoldingBuilder.kt @@ -0,0 +1,117 @@ +package com.zlup.ide + +import com.intellij.lang.ASTNode +import com.intellij.lang.folding.FoldingBuilderEx +import com.intellij.lang.folding.FoldingDescriptor +import com.intellij.openapi.editor.Document +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import com.intellij.psi.util.PsiTreeUtil + +class ZlupFoldingBuilder : FoldingBuilderEx() { + override fun buildFoldRegions(root: PsiElement, document: Document, quick: Boolean): Array { + val descriptors = mutableListOf() + val text = root.text + + // Find all brace pairs for folding + findBracePairs(text, '{', '}', root, descriptors) + + // Find block comments + findBlockComments(text, root, descriptors) + + return descriptors.toTypedArray() + } + + private fun findBracePairs( + text: String, + openChar: Char, + closeChar: Char, + root: PsiElement, + descriptors: MutableList + ) { + val stack = mutableListOf() + var i = 0 + + while (i < text.length) { + when { + text[i] == openChar -> { + stack.add(i) + } + text[i] == closeChar && stack.isNotEmpty() -> { + val openIndex = stack.removeAt(stack.size - 1) + val closeIndex = i + + // Only fold if the region spans multiple lines + val openLine = text.substring(0, openIndex).count { it == '\n' } + val closeLine = text.substring(0, closeIndex).count { it == '\n' } + + if (closeLine > openLine) { + val range = TextRange(openIndex, closeIndex + 1) + if (range.length > 1) { + descriptors.add(FoldingDescriptor(root.node, range)) + } + } + } + // Skip strings + text[i] == '"' -> { + i++ + while (i < text.length && text[i] != '"') { + if (text[i] == '\\' && i + 1 < text.length) i++ + i++ + } + } + // Skip line comments + text[i] == '/' && i + 1 < text.length && text[i + 1] == '/' -> { + while (i < text.length && text[i] != '\n') i++ + } + // Skip block comments + text[i] == '/' && i + 1 < text.length && text[i + 1] == '*' -> { + i += 2 + while (i + 1 < text.length && !(text[i] == '*' && text[i + 1] == '/')) i++ + i++ + } + } + i++ + } + } + + private fun findBlockComments( + text: String, + root: PsiElement, + descriptors: MutableList + ) { + var i = 0 + while (i < text.length - 1) { + if (text[i] == '/' && text[i + 1] == '*') { + val start = i + i += 2 + while (i + 1 < text.length && !(text[i] == '*' && text[i + 1] == '/')) { + i++ + } + val end = i + 2 + if (end <= text.length) { + val range = TextRange(start, end) + val openLine = text.substring(0, start).count { it == '\n' } + val closeLine = text.substring(0, end).count { it == '\n' } + if (closeLine > openLine && range.length > 2) { + descriptors.add(FoldingDescriptor(root.node, range)) + } + } + i = end + } else { + i++ + } + } + } + + override fun getPlaceholderText(node: ASTNode): String { + val text = node.text + return when { + text.startsWith("{") -> "{...}" + text.startsWith("/*") -> "/*...*/" + else -> "..." + } + } + + override fun isCollapsedByDefault(node: ASTNode): Boolean = false +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupIcons.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupIcons.kt new file mode 100644 index 000000000..656753d9f --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupIcons.kt @@ -0,0 +1,9 @@ +package com.zlup.ide + +import com.intellij.openapi.util.IconLoader +import javax.swing.Icon + +object ZlupIcons { + @JvmField + val FILE: Icon = IconLoader.getIcon("/icons/zlup.svg", ZlupIcons::class.java) +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLanguage.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLanguage.kt new file mode 100644 index 000000000..f87168e5c --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLanguage.kt @@ -0,0 +1,8 @@ +package com.zlup.ide + +import com.intellij.lang.Language + +object ZlupLanguage : Language("Zlup") { + override fun getDisplayName(): String = "Zlup" + override fun isCaseSensitive(): Boolean = true +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLexer.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLexer.kt new file mode 100644 index 000000000..7a2733c97 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLexer.kt @@ -0,0 +1,233 @@ +package com.zlup.ide + +import com.intellij.lexer.LexerBase +import com.intellij.psi.tree.IElementType + +class ZlupLexer : LexerBase() { + private var buffer: CharSequence = "" + private var startOffset: Int = 0 + private var endOffset: Int = 0 + private var currentOffset: Int = 0 + private var tokenStart: Int = 0 + private var tokenEnd: Int = 0 + private var tokenType: IElementType? = null + + companion object { + private val KEYWORDS = setOf( + // Control flow + "fn", "if", "else", "while", "for", "return", "break", "continue", + // Declarations + "const", "var", "pub", "struct", "enum", "union", "error", "type", + // Error handling + "try", "catch", "orelse", "defer", "errdefer", + // Modifiers + "mut", "comptime", "inline", "packed", "extern", + // Logical operators + "and", "or", "not", + // Literals + "true", "false", "null", "undefined", "unit", + // Other + "in", "tick", "barrier", "fault" + ) + + private val TYPES = setOf( + // Integer types + "u1", "u2", "u4", "u8", "u16", "u32", "u64", "u128", + "i8", "i16", "i32", "i64", "i128", + "usize", "isize", + // Float types + "f16", "f32", "f64", "f128", + // Angle type + "a64", + // Boolean + "bool", + // Quantum types + "qubit", "bit", "qalloc", + // Special + "void", "type", "anytype", "anyerror", "anyfault" + ) + + private val GATES = setOf( + // Single-qubit gates (lowercase) + "h", "x", "y", "z", "s", "sdg", "t", "tdg", + "sx", "sxdg", "sy", "sydg", "sz", "szdg", + "f", "fdg", "f4", "f4dg", + // Rotations + "rx", "ry", "rz", + // Two-qubit gates + "cx", "cy", "cz", "ch", "swap", "iswap", + "sxx", "sxxdg", "syy", "syydg", "szz", "szzdg", "rzz", + // Three-qubit gates + "ccx", + // Measurement/preparation + "mz", "mx", "my", "pz", "px", "py" + ) + } + + override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) { + this.buffer = buffer + this.startOffset = startOffset + this.endOffset = endOffset + this.currentOffset = startOffset + advance() + } + + override fun getState(): Int = 0 + + override fun getTokenType(): IElementType? = tokenType + + override fun getTokenStart(): Int = tokenStart + + override fun getTokenEnd(): Int = tokenEnd + + override fun advance() { + tokenStart = currentOffset + + if (currentOffset >= endOffset) { + tokenType = null + tokenEnd = currentOffset + return + } + + val c = buffer[currentOffset] + + when { + // Whitespace + c.isWhitespace() -> { + while (currentOffset < endOffset && buffer[currentOffset].isWhitespace()) { + currentOffset++ + } + tokenType = ZlupTokenTypes.WHITE_SPACE + } + + // Line comment + c == '/' && currentOffset + 1 < endOffset && buffer[currentOffset + 1] == '/' -> { + currentOffset += 2 + while (currentOffset < endOffset && buffer[currentOffset] != '\n') { + currentOffset++ + } + tokenType = ZlupTokenTypes.LINE_COMMENT + } + + // Block comment + c == '/' && currentOffset + 1 < endOffset && buffer[currentOffset + 1] == '*' -> { + currentOffset += 2 + while (currentOffset + 1 < endOffset) { + if (buffer[currentOffset] == '*' && buffer[currentOffset + 1] == '/') { + currentOffset += 2 + break + } + currentOffset++ + } + tokenType = ZlupTokenTypes.BLOCK_COMMENT + } + + // String + c == '"' -> { + currentOffset++ + while (currentOffset < endOffset) { + val ch = buffer[currentOffset] + if (ch == '"') { + currentOffset++ + break + } + if (ch == '\\' && currentOffset + 1 < endOffset) { + currentOffset += 2 + } else { + currentOffset++ + } + } + tokenType = ZlupTokenTypes.STRING + } + + // Character literal + c == '\'' -> { + currentOffset++ + while (currentOffset < endOffset) { + val ch = buffer[currentOffset] + if (ch == '\'') { + currentOffset++ + break + } + if (ch == '\\' && currentOffset + 1 < endOffset) { + currentOffset += 2 + } else { + currentOffset++ + } + } + tokenType = ZlupTokenTypes.STRING + } + + // Number + c.isDigit() -> { + while (currentOffset < endOffset) { + val ch = buffer[currentOffset] + if (ch.isLetterOrDigit() || ch == '.' || ch == '_') { + currentOffset++ + } else { + break + } + } + tokenType = ZlupTokenTypes.NUMBER + } + + // Identifier or keyword + c.isLetter() || c == '_' -> { + while (currentOffset < endOffset) { + val ch = buffer[currentOffset] + if (ch.isLetterOrDigit() || ch == '_') { + currentOffset++ + } else { + break + } + } + val word = buffer.subSequence(tokenStart, currentOffset).toString() + tokenType = when { + word in KEYWORDS -> ZlupTokenTypes.KEYWORD + word in TYPES -> ZlupTokenTypes.TYPE + word in GATES -> ZlupTokenTypes.GATE + else -> ZlupTokenTypes.IDENTIFIER + } + } + + // Arrow + c == '-' && currentOffset + 1 < endOffset && buffer[currentOffset + 1] == '>' -> { + currentOffset += 2 + tokenType = ZlupTokenTypes.ARROW + } + + // Brackets and punctuation + c == '(' -> { currentOffset++; tokenType = ZlupTokenTypes.LPAREN } + c == ')' -> { currentOffset++; tokenType = ZlupTokenTypes.RPAREN } + c == '{' -> { currentOffset++; tokenType = ZlupTokenTypes.LBRACE } + c == '}' -> { currentOffset++; tokenType = ZlupTokenTypes.RBRACE } + c == '[' -> { currentOffset++; tokenType = ZlupTokenTypes.LBRACKET } + c == ']' -> { currentOffset++; tokenType = ZlupTokenTypes.RBRACKET } + c == '.' -> { currentOffset++; tokenType = ZlupTokenTypes.DOT } + c == ',' -> { currentOffset++; tokenType = ZlupTokenTypes.COMMA } + c == ';' -> { currentOffset++; tokenType = ZlupTokenTypes.SEMICOLON } + c == ':' -> { currentOffset++; tokenType = ZlupTokenTypes.COLON } + c == '@' -> { currentOffset++; tokenType = ZlupTokenTypes.AT } + + // Operators + c in "+-*/%=!<>&|^~" -> { + while (currentOffset < endOffset && buffer[currentOffset] in "+-*/%=!<>&|^~") { + currentOffset++ + } + tokenType = ZlupTokenTypes.OPERATOR + } + + // Bad character + else -> { + currentOffset++ + tokenType = ZlupTokenTypes.BAD_CHARACTER + } + } + + tokenEnd = currentOffset + } + + override fun getBufferSequence(): CharSequence = buffer + + override fun getBufferEnd(): Int = endOffset +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParser.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParser.kt new file mode 100644 index 000000000..cf0546d77 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParser.kt @@ -0,0 +1,24 @@ +package com.zlup.ide + +import com.intellij.lang.ASTNode +import com.intellij.lang.PsiBuilder +import com.intellij.lang.PsiParser +import com.intellij.psi.tree.IElementType + +/** + * Minimal parser for Zlup. + * Actual parsing is handled by the LSP server - this just provides + * basic token stream to PSI tree conversion. + */ +class ZlupParser : PsiParser { + override fun parse(root: IElementType, builder: PsiBuilder): ASTNode { + val rootMarker = builder.mark() + + while (!builder.eof()) { + builder.advanceLexer() + } + + rootMarker.done(root) + return builder.treeBuilt + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParserDefinition.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParserDefinition.kt new file mode 100644 index 000000000..f3670e6c6 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParserDefinition.kt @@ -0,0 +1,35 @@ +package com.zlup.ide + +import com.intellij.lang.ASTNode +import com.intellij.lang.ParserDefinition +import com.intellij.lang.PsiParser +import com.intellij.lexer.Lexer +import com.intellij.openapi.project.Project +import com.intellij.psi.FileViewProvider +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.tree.IFileElementType +import com.intellij.psi.tree.TokenSet + +class ZlupParserDefinition : ParserDefinition { + companion object { + val FILE = IFileElementType(ZlupLanguage) + } + + override fun createLexer(project: Project): Lexer = ZlupLexer() + + override fun getCommentTokens(): TokenSet = ZlupTokenTypes.COMMENTS + + override fun getStringLiteralElements(): TokenSet = ZlupTokenTypes.STRINGS + + override fun createParser(project: Project): PsiParser { + // We use LSP for parsing, so we provide a minimal parser + return ZlupParser() + } + + override fun getFileNodeType(): IFileElementType = FILE + + override fun createFile(viewProvider: FileViewProvider): PsiFile = ZlupFile(viewProvider) + + override fun createElement(node: ASTNode): PsiElement = ZlupPsiElement(node) +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupPsiElement.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupPsiElement.kt new file mode 100644 index 000000000..5cd683259 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupPsiElement.kt @@ -0,0 +1,6 @@ +package com.zlup.ide + +import com.intellij.extapi.psi.ASTWrapperPsiElement +import com.intellij.lang.ASTNode + +open class ZlupPsiElement(node: ASTNode) : ASTWrapperPsiElement(node) diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupSyntaxHighlighter.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupSyntaxHighlighter.kt new file mode 100644 index 000000000..8dd947de7 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupSyntaxHighlighter.kt @@ -0,0 +1,82 @@ +package com.zlup.ide + +import com.intellij.lexer.Lexer +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.editor.colors.TextAttributesKey.createTextAttributesKey +import com.intellij.openapi.fileTypes.SyntaxHighlighter +import com.intellij.openapi.fileTypes.SyntaxHighlighterBase +import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.tree.IElementType + +class ZlupSyntaxHighlighter : SyntaxHighlighterBase() { + companion object { + val KEYWORD = createTextAttributesKey("ZLUP_KEYWORD", DefaultLanguageHighlighterColors.KEYWORD) + val TYPE = createTextAttributesKey("ZLUP_TYPE", DefaultLanguageHighlighterColors.CLASS_NAME) + val GATE = createTextAttributesKey("ZLUP_GATE", DefaultLanguageHighlighterColors.FUNCTION_CALL) + val IDENTIFIER = createTextAttributesKey("ZLUP_IDENTIFIER", DefaultLanguageHighlighterColors.IDENTIFIER) + val NUMBER = createTextAttributesKey("ZLUP_NUMBER", DefaultLanguageHighlighterColors.NUMBER) + val STRING = createTextAttributesKey("ZLUP_STRING", DefaultLanguageHighlighterColors.STRING) + val LINE_COMMENT = createTextAttributesKey("ZLUP_LINE_COMMENT", DefaultLanguageHighlighterColors.LINE_COMMENT) + val BLOCK_COMMENT = createTextAttributesKey("ZLUP_BLOCK_COMMENT", DefaultLanguageHighlighterColors.BLOCK_COMMENT) + val OPERATOR = createTextAttributesKey("ZLUP_OPERATOR", DefaultLanguageHighlighterColors.OPERATION_SIGN) + val BRACKETS = createTextAttributesKey("ZLUP_BRACKETS", DefaultLanguageHighlighterColors.BRACKETS) + val BRACES = createTextAttributesKey("ZLUP_BRACES", DefaultLanguageHighlighterColors.BRACES) + val PARENTHESES = createTextAttributesKey("ZLUP_PARENTHESES", DefaultLanguageHighlighterColors.PARENTHESES) + val COMMA = createTextAttributesKey("ZLUP_COMMA", DefaultLanguageHighlighterColors.COMMA) + val SEMICOLON = createTextAttributesKey("ZLUP_SEMICOLON", DefaultLanguageHighlighterColors.SEMICOLON) + val DOT = createTextAttributesKey("ZLUP_DOT", DefaultLanguageHighlighterColors.DOT) + val BAD_CHARACTER = createTextAttributesKey("ZLUP_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER) + + private val KEYWORD_KEYS = arrayOf(KEYWORD) + private val TYPE_KEYS = arrayOf(TYPE) + private val GATE_KEYS = arrayOf(GATE) + private val IDENTIFIER_KEYS = arrayOf(IDENTIFIER) + private val NUMBER_KEYS = arrayOf(NUMBER) + private val STRING_KEYS = arrayOf(STRING) + private val COMMENT_KEYS = arrayOf(LINE_COMMENT) + private val BLOCK_COMMENT_KEYS = arrayOf(BLOCK_COMMENT) + private val OPERATOR_KEYS = arrayOf(OPERATOR) + private val BRACKET_KEYS = arrayOf(BRACKETS) + private val BRACE_KEYS = arrayOf(BRACES) + private val PAREN_KEYS = arrayOf(PARENTHESES) + private val COMMA_KEYS = arrayOf(COMMA) + private val SEMICOLON_KEYS = arrayOf(SEMICOLON) + private val DOT_KEYS = arrayOf(DOT) + private val BAD_CHAR_KEYS = arrayOf(BAD_CHARACTER) + private val EMPTY_KEYS = emptyArray() + } + + override fun getHighlightingLexer(): Lexer = ZlupLexer() + + override fun getTokenHighlights(tokenType: IElementType): Array { + return when (tokenType) { + ZlupTokenTypes.KEYWORD -> KEYWORD_KEYS + ZlupTokenTypes.TYPE -> TYPE_KEYS + ZlupTokenTypes.GATE -> GATE_KEYS + ZlupTokenTypes.IDENTIFIER -> IDENTIFIER_KEYS + ZlupTokenTypes.NUMBER -> NUMBER_KEYS + ZlupTokenTypes.STRING -> STRING_KEYS + ZlupTokenTypes.LINE_COMMENT -> COMMENT_KEYS + ZlupTokenTypes.BLOCK_COMMENT -> BLOCK_COMMENT_KEYS + ZlupTokenTypes.OPERATOR, ZlupTokenTypes.ARROW -> OPERATOR_KEYS + ZlupTokenTypes.LBRACKET, ZlupTokenTypes.RBRACKET -> BRACKET_KEYS + ZlupTokenTypes.LBRACE, ZlupTokenTypes.RBRACE -> BRACE_KEYS + ZlupTokenTypes.LPAREN, ZlupTokenTypes.RPAREN -> PAREN_KEYS + ZlupTokenTypes.COMMA -> COMMA_KEYS + ZlupTokenTypes.SEMICOLON -> SEMICOLON_KEYS + ZlupTokenTypes.DOT -> DOT_KEYS + ZlupTokenTypes.BAD_CHARACTER -> BAD_CHAR_KEYS + else -> EMPTY_KEYS + } + } +} + +class ZlupSyntaxHighlighterFactory : SyntaxHighlighterFactory() { + override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter { + return ZlupSyntaxHighlighter() + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTemplateContextType.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTemplateContextType.kt new file mode 100644 index 000000000..f43e0cd4c --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTemplateContextType.kt @@ -0,0 +1,11 @@ +package com.zlup.ide + +import com.intellij.codeInsight.template.TemplateActionContext +import com.intellij.codeInsight.template.TemplateContextType + +class ZlupTemplateContextType : TemplateContextType("Zlup") { + override fun isInContext(templateActionContext: TemplateActionContext): Boolean { + val file = templateActionContext.file + return file.name.endsWith(".zlp") + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTokenTypes.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTokenTypes.kt new file mode 100644 index 000000000..90b2f5337 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTokenTypes.kt @@ -0,0 +1,56 @@ +package com.zlup.ide + +import com.intellij.psi.tree.IElementType +import com.intellij.psi.tree.TokenSet + +object ZlupTokenTypes { + // Keywords + @JvmField val KEYWORD = ZlupTokenType("KEYWORD") + + // Identifiers + @JvmField val IDENTIFIER = ZlupTokenType("IDENTIFIER") + + // Literals + @JvmField val NUMBER = ZlupTokenType("NUMBER") + @JvmField val STRING = ZlupTokenType("STRING") + + // Comments + @JvmField val LINE_COMMENT = ZlupTokenType("LINE_COMMENT") + @JvmField val BLOCK_COMMENT = ZlupTokenType("BLOCK_COMMENT") + + // Operators + @JvmField val OPERATOR = ZlupTokenType("OPERATOR") + + // Brackets + @JvmField val LPAREN = ZlupTokenType("LPAREN") + @JvmField val RPAREN = ZlupTokenType("RPAREN") + @JvmField val LBRACE = ZlupTokenType("LBRACE") + @JvmField val RBRACE = ZlupTokenType("RBRACE") + @JvmField val LBRACKET = ZlupTokenType("LBRACKET") + @JvmField val RBRACKET = ZlupTokenType("RBRACKET") + + // Other + @JvmField val DOT = ZlupTokenType("DOT") + @JvmField val COMMA = ZlupTokenType("COMMA") + @JvmField val SEMICOLON = ZlupTokenType("SEMICOLON") + @JvmField val COLON = ZlupTokenType("COLON") + @JvmField val ARROW = ZlupTokenType("ARROW") + @JvmField val AT = ZlupTokenType("AT") + + // Whitespace and bad characters + @JvmField val WHITE_SPACE = ZlupTokenType("WHITE_SPACE") + @JvmField val BAD_CHARACTER = ZlupTokenType("BAD_CHARACTER") + + // Types (built-in) + @JvmField val TYPE = ZlupTokenType("TYPE") + + // Quantum gates + @JvmField val GATE = ZlupTokenType("GATE") + + // Token sets + @JvmField val COMMENTS = TokenSet.create(LINE_COMMENT, BLOCK_COMMENT) + @JvmField val STRINGS = TokenSet.create(STRING) + @JvmField val WHITESPACES = TokenSet.create(WHITE_SPACE) +} + +class ZlupTokenType(debugName: String) : IElementType(debugName, ZlupLanguage) diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/resources/META-INF/plugin.xml b/exp/zlup/editors/jetbrains-zlup/src/main/resources/META-INF/plugin.xml new file mode 100644 index 000000000..73585273b --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,96 @@ + + com.zlup.ide + Zlup + PECOS + + Language support for Zlup - a quantum programming language.

    + +

    Features

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

    Supported Constructs

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

    0.1.0

    +
      +
    • Initial release
    • +
    • Basic syntax highlighting
    • +
    • Brace matching
    • +
    + ]]>
    + + com.intellij.modules.platform + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/resources/icons/zlup.svg b/exp/zlup/editors/jetbrains-zlup/src/main/resources/icons/zlup.svg new file mode 100644 index 000000000..8307a2aa3 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/resources/icons/zlup.svg @@ -0,0 +1,9 @@ + + + + + Q + + + + diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/resources/liveTemplates/Zlup.xml b/exp/zlup/editors/jetbrains-zlup/src/main/resources/liveTemplates/Zlup.xml new file mode 100644 index 000000000..3fad55182 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/resources/liveTemplates/Zlup.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/exp/zlup/editors/vscode-zlup/README.md b/exp/zlup/editors/vscode-zlup/README.md new file mode 100644 index 000000000..0f2655d4c --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/README.md @@ -0,0 +1,72 @@ +# Zlup for Visual Studio Code + +Language support for Zlup - a quantum programming language. + +## Features + +- **Syntax Highlighting**: Full syntax highlighting for Zlup source files (.zlp) +- **Code Snippets**: Quick snippets for common patterns +- **Bracket Matching**: Automatic bracket and brace matching +- **Code Folding**: Fold functions and blocks +- **Commenting**: Toggle line and block comments + +## Supported Constructs + +### Quantum Gates +- Single-qubit: `h`, `x`, `y`, `z`, `s`, `t`, `rx`, `ry`, `rz` +- Two-qubit: `cx`, `cz`, `swap`, `iswap`, `rzz` +- Three-qubit: `ccx` +- Measurement: `mz`, `mx`, `my` +- Preparation: `pz`, `px`, `py` + +### Types +- Integers: `u8`, `u16`, `u32`, `u64`, `i8`, `i16`, `i32`, `i64` +- Floats: `f32`, `f64` +- Angles: `a64` +- Quantum: `qubit`, `bit`, `qalloc` + +### Control Flow +- `if`/`else` +- `for`/`while` +- `tick` (parallel quantum ops) +- `barrier` + +## Snippets + +| Prefix | Description | +|--------|-------------| +| `main` | Main function | +| `fn` | Function definition | +| `qalloc` | Qubit allocation | +| `bell` | Bell state preparation | +| `ghz` | GHZ state preparation | +| `for` | For loop | +| `if`/`ife` | If/if-else statement | +| `tick` | Tick block | +| `meas` | Typed measurement | + +## Installation + +### From VSIX +1. Download the `.vsix` file +2. In VS Code: Extensions > ... > Install from VSIX + +### From Source +```bash +cd vscode-zlup +npm install +npm run package +``` + +## Example + +```zlup +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + return; +} +``` diff --git a/exp/zlup/editors/vscode-zlup/language-configuration.json b/exp/zlup/editors/vscode-zlup/language-configuration.json new file mode 100644 index 000000000..054f14a54 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/language-configuration.json @@ -0,0 +1,35 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"", "notIn": ["string"] }, + { "open": "'", "close": "'", "notIn": ["string"] } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"] + ], + "folding": { + "markers": { + "start": "^\\s*//\\s*#?region\\b", + "end": "^\\s*//\\s*#?endregion\\b" + } + }, + "indentationRules": { + "increaseIndentPattern": "^.*\\{[^}\"']*$|^.*\\([^)\"']*$|^.*\\[[^\\]\"']*$", + "decreaseIndentPattern": "^\\s*(\\}|\\)|\\]).*$" + } +} diff --git a/exp/zlup/editors/vscode-zlup/package-lock.json b/exp/zlup/editors/vscode-zlup/package-lock.json new file mode 100644 index 000000000..a455672c7 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "zlup", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zlup", + "version": "0.1.0", + "engines": { + "vscode": "^1.74.0" + } + } + } +} diff --git a/exp/zlup/editors/vscode-zlup/package.json b/exp/zlup/editors/vscode-zlup/package.json new file mode 100644 index 000000000..a5feb73a6 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/package.json @@ -0,0 +1,45 @@ +{ + "name": "zlup", + "displayName": "Zlup", + "description": "Language support for Zlup - a quantum programming language", + "version": "0.1.0", + "publisher": "pecos", + "engines": { + "vscode": "^1.74.0" + }, + "categories": [ + "Programming Languages" + ], + "repository": { + "type": "git", + "url": "https://github.com/PECOS-packages/PECOS" + }, + "contributes": { + "languages": [ + { + "id": "zlup", + "aliases": [ + "Zlup", + "zlup" + ], + "extensions": [ + ".zlp" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "zlup", + "scopeName": "source.zlup", + "path": "./syntaxes/zlup.tmLanguage.json" + } + ], + "snippets": [ + { + "language": "zlup", + "path": "./snippets/zlup.json" + } + ] + } +} diff --git a/exp/zlup/editors/vscode-zlup/snippets/zlup.json b/exp/zlup/editors/vscode-zlup/snippets/zlup.json new file mode 100644 index 000000000..915b79f12 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/snippets/zlup.json @@ -0,0 +1,176 @@ +{ + "Main Function": { + "prefix": "main", + "body": [ + "pub fn main() -> unit {", + " $0", + " return unit;", + "}" + ], + "description": "Main function template" + }, + "Function": { + "prefix": "fn", + "body": [ + "fn ${1:name}(${2:params}) -> ${3:unit} {", + " $0", + "}" + ], + "description": "Function definition" + }, + "Public Function": { + "prefix": "pubfn", + "body": [ + "pub fn ${1:name}(${2:params}) -> ${3:unit} {", + " $0", + "}" + ], + "description": "Public function definition" + }, + "Qubit Allocation": { + "prefix": "qalloc", + "body": [ + "${1:q} := qalloc(${2:2});" + ], + "description": "Allocate qubits" + }, + "Bell State": { + "prefix": "bell", + "body": [ + "// Bell state preparation", + "${1:q} := qalloc(2);", + "pz ${1:q};", + "h ${1:q}[0];", + "cx (${1:q}[0], ${1:q}[1]);", + "$0" + ], + "description": "Bell state preparation" + }, + "GHZ State": { + "prefix": "ghz", + "body": [ + "// GHZ state preparation", + "${1:q} := qalloc(${2:4});", + "pz ${1:q};", + "h ${1:q}[0];", + "for i in 1..${2:4} {", + " cx (${1:q}[0], ${1:q}[i]);", + "}", + "$0" + ], + "description": "GHZ state preparation" + }, + "For Loop": { + "prefix": "for", + "body": [ + "for ${1:i} in ${2:0}..${3:n} {", + " $0", + "}" + ], + "description": "For loop" + }, + "If Statement": { + "prefix": "if", + "body": [ + "if ${1:condition} {", + " $0", + "}" + ], + "description": "If statement" + }, + "If-Else Statement": { + "prefix": "ife", + "body": [ + "if ${1:condition} {", + " $2", + "} else {", + " $0", + "}" + ], + "description": "If-else statement" + }, + "Tick Block": { + "prefix": "tick", + "body": [ + "tick {", + " $0", + "}" + ], + "description": "Tick block for parallel quantum operations" + }, + "Typed Measurement": { + "prefix": "meas", + "body": [ + "${1:results}: [${2:2}]u1 = mz([${2:2}]u1) [${3:q[0], q[1]}];" + ], + "description": "Typed measurement" + }, + "Struct Definition": { + "prefix": "struct", + "body": [ + "const ${1:Name} = struct {", + " ${2:field}: ${3:type},", + "};" + ], + "description": "Struct definition" + }, + "Enum Definition": { + "prefix": "enum", + "body": [ + "const ${1:Name} = enum {", + " ${2:variant1},", + " ${3:variant2},", + "};" + ], + "description": "Enum definition" + }, + "Import": { + "prefix": "import", + "body": [ + "${1:std} := @import(\"${2:std}\");" + ], + "description": "Import statement" + }, + "Constant": { + "prefix": "const", + "body": [ + "const ${1:NAME}: ${2:type} = ${3:value};" + ], + "description": "Constant declaration" + }, + "Hadamard Gate": { + "prefix": "hadamard", + "body": [ + "h ${1:q}[${2:0}];" + ], + "description": "Hadamard gate" + }, + "CNOT Gate": { + "prefix": "cnot", + "body": [ + "cx (${1:q}[${2:0}], ${1:q}[${3:1}]);" + ], + "description": "CNOT gate" + }, + "Rotation X": { + "prefix": "rotx", + "body": [ + "rx(${1:0.5}turn) ${2:q}[${3:0}];" + ], + "description": "Rotation around X axis" + }, + "Rotation Y": { + "prefix": "roty", + "body": [ + "ry(${1:0.5}turn) ${2:q}[${3:0}];" + ], + "description": "Rotation around Y axis" + }, + "Rotation Z": { + "prefix": "rotz", + "body": [ + "rz(${1:0.5}turn) ${2:q}[${3:0}];" + ], + "description": "Rotation around Z axis" + } +} diff --git a/exp/zlup/editors/vscode-zlup/syntaxes/zlup.tmLanguage.json b/exp/zlup/editors/vscode-zlup/syntaxes/zlup.tmLanguage.json new file mode 100644 index 000000000..e315b8098 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/syntaxes/zlup.tmLanguage.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Zlup", + "scopeName": "source.zlup", + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#numbers" }, + { "include": "#keywords" }, + { "include": "#types" }, + { "include": "#gates" }, + { "include": "#builtins" }, + { "include": "#operators" }, + { "include": "#punctuation" } + ], + "repository": { + "comments": { + "patterns": [ + { + "name": "comment.line.double-slash.zlup", + "match": "//.*$" + }, + { + "name": "comment.block.zlup", + "begin": "/\\*", + "end": "\\*/" + }, + { + "name": "comment.line.documentation.zlup", + "match": "///.*$" + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.zlup", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.zlup", + "match": "\\\\." + } + ] + }, + { + "name": "string.quoted.single.zlup", + "begin": "'", + "end": "'", + "patterns": [ + { + "name": "constant.character.escape.zlup", + "match": "\\\\." + } + ] + } + ] + }, + "numbers": { + "patterns": [ + { + "name": "constant.numeric.hex.zlup", + "match": "\\b0x[0-9a-fA-F_]+\\b" + }, + { + "name": "constant.numeric.binary.zlup", + "match": "\\b0b[01_]+\\b" + }, + { + "name": "constant.numeric.octal.zlup", + "match": "\\b0o[0-7_]+\\b" + }, + { + "name": "constant.numeric.float.zlup", + "match": "\\b[0-9][0-9_]*\\.[0-9][0-9_]*([eE][+-]?[0-9_]+)?\\b" + }, + { + "name": "constant.numeric.integer.zlup", + "match": "\\b[0-9][0-9_]*\\b" + }, + { + "name": "constant.numeric.angle.zlup", + "match": "\\b[0-9][0-9_]*\\.?[0-9_]*(deg|rad|turn)\\b" + } + ] + }, + "keywords": { + "patterns": [ + { + "name": "keyword.control.zlup", + "match": "\\b(if|else|while|for|return|break|continue|tick|barrier)\\b" + }, + { + "name": "keyword.declaration.zlup", + "match": "\\b(fn|const|struct|enum|union|error|type|fault)\\b" + }, + { + "name": "keyword.modifier.zlup", + "match": "\\b(pub|mut|comptime|inline|packed|extern)\\b" + }, + { + "name": "keyword.error-handling.zlup", + "match": "\\b(try|catch|orelse|defer|errdefer)\\b" + }, + { + "name": "keyword.operator.logical.zlup", + "match": "\\b(and|or|not)\\b" + }, + { + "name": "keyword.other.zlup", + "match": "\\b(in|var)\\b" + }, + { + "name": "constant.language.zlup", + "match": "\\b(true|false|null|undefined|unit)\\b" + } + ] + }, + "types": { + "patterns": [ + { + "name": "storage.type.integer.zlup", + "match": "\\b(u1|u2|u4|u8|u16|u32|u64|u128|i8|i16|i32|i64|i128|usize|isize)\\b" + }, + { + "name": "storage.type.float.zlup", + "match": "\\b(f16|f32|f64|f128)\\b" + }, + { + "name": "storage.type.angle.zlup", + "match": "\\b(a64)\\b" + }, + { + "name": "storage.type.quantum.zlup", + "match": "\\b(qubit|bit|qalloc)\\b" + }, + { + "name": "storage.type.primitive.zlup", + "match": "\\b(bool|void)\\b" + }, + { + "name": "storage.type.special.zlup", + "match": "\\b(type|anytype|anyerror|anyfault)\\b" + } + ] + }, + "gates": { + "patterns": [ + { + "name": "entity.name.function.gate.single.zlup", + "match": "\\b(h|x|y|z|s|sdg|t|tdg|sx|sxdg|sy|sydg|sz|szdg|f|fdg|f4|f4dg)\\b" + }, + { + "name": "entity.name.function.gate.rotation.zlup", + "match": "\\b(rx|ry|rz)\\b" + }, + { + "name": "entity.name.function.gate.two-qubit.zlup", + "match": "\\b(cx|cy|cz|ch|swap|iswap|sxx|sxxdg|syy|syydg|szz|szzdg|rzz)\\b" + }, + { + "name": "entity.name.function.gate.three-qubit.zlup", + "match": "\\b(ccx)\\b" + }, + { + "name": "entity.name.function.measurement.zlup", + "match": "\\b(mz|mx|my)\\b" + }, + { + "name": "entity.name.function.preparation.zlup", + "match": "\\b(pz|px|py)\\b" + } + ] + }, + "builtins": { + "patterns": [ + { + "name": "support.function.builtin.zlup", + "match": "@(import|sizeof|alignof|typeOf|intCast|floatCast|truncate|bitCast|ptrCast|min|max|clamp|sqrt|sin|cos|tan|log|log2|log10|exp|floor|ceil|round|abs|mod|divFloor|divTrunc)\\b" + } + ] + }, + "operators": { + "patterns": [ + { + "name": "keyword.operator.assignment.zlup", + "match": ":=|=" + }, + { + "name": "keyword.operator.comparison.zlup", + "match": "==|!=|<=|>=|<|>" + }, + { + "name": "keyword.operator.arithmetic.zlup", + "match": "\\+|-|\\*|/|%" + }, + { + "name": "keyword.operator.bitwise.zlup", + "match": "&|\\||\\^|~|<<|>>" + }, + { + "name": "keyword.operator.arrow.zlup", + "match": "->" + }, + { + "name": "keyword.operator.optional.zlup", + "match": "\\?|\\!" + } + ] + }, + "punctuation": { + "patterns": [ + { + "name": "punctuation.separator.zlup", + "match": ",|;|:" + }, + { + "name": "punctuation.accessor.zlup", + "match": "\\." + }, + { + "name": "punctuation.bracket.zlup", + "match": "[\\[\\]\\{\\}\\(\\)]" + } + ] + } + } +} diff --git a/exp/zlup/examples/README.md b/exp/zlup/examples/README.md new file mode 100644 index 000000000..f89d4d1f5 --- /dev/null +++ b/exp/zlup/examples/README.md @@ -0,0 +1,50 @@ +# Zlup Examples + +This directory contains example programs demonstrating Zlup features and common quantum algorithms. + +## Running Examples + +```bash +# Compile to SLR-AST JSON +zlup compile examples/bell_state.zlp -o bell_state.json + +# Check syntax and semantics +zlup check examples/bell_state.zlp +``` + +## Examples + +### Basic Quantum States + +| File | Description | +|------|-------------| +| `bell_state.zlp` | Creates the Bell state (|00⟩ + |11⟩) / sqrt(2) | +| `ghz_state.zlp` | Creates an N-qubit GHZ state | + +### Quantum Algorithms + +| File | Description | +|------|-------------| +| `teleportation.zlp` | Quantum teleportation protocol | +| `grover_2qubit.zlp` | Grover's search algorithm for 2 qubits | +| `qft_3qubit.zlp` | Quantum Fourier Transform on 3 qubits | + +### Error Correction + +| File | Description | +|------|-------------| +| `simple_qec.zlp` | 3-qubit bit-flip code with syndrome measurement | + +### Testing + +| File | Description | +|------|-------------| +| `test_lsp.zlp` | Test file for LSP functionality | + +## Learning Path + +1. Start with `bell_state.zlp` to understand basic gates and entanglement +2. Move to `ghz_state.zlp` to see loops and multiple qubits +3. Try `teleportation.zlp` for a complete protocol +4. Explore `simple_qec.zlp` for error correction concepts +5. Study `grover_2qubit.zlp` and `qft_3qubit.zlp` for algorithms diff --git a/exp/zlup/examples/bell_state.json b/exp/zlup/examples/bell_state.json new file mode 100644 index 000000000..e7940139d --- /dev/null +++ b/exp/zlup/examples/bell_state.json @@ -0,0 +1,86 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 2 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 2 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + } + ], + "result_type": "u1" + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c1", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} diff --git a/exp/zlup/examples/bell_state.qasm b/exp/zlup/examples/bell_state.qasm new file mode 100644 index 000000000..e1aca3d75 --- /dev/null +++ b/exp/zlup/examples/bell_state.qasm @@ -0,0 +1,10 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[2]; +creg c[2]; + +h q[0]; +cx q[0], q[1]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; diff --git a/exp/zlup/examples/bell_state.zlp b/exp/zlup/examples/bell_state.zlp new file mode 100644 index 000000000..878808f47 --- /dev/null +++ b/exp/zlup/examples/bell_state.zlp @@ -0,0 +1,27 @@ +/// Bell State Example +/// Creates the maximally entangled Bell state: (|00⟩ + |11⟩) / sqrt(2) +/// +/// When measured, both qubits will always give the same result: +/// either both 0 or both 1, with 50% probability each. + +pub fn main() -> unit { + // Allocate two qubits + q := qalloc(2); + + // Prepare both qubits to |0⟩ + pz q; + + // Create superposition on first qubit: |0⟩ -> (|0⟩ + |1⟩) / sqrt(2) + h q[0]; + + // Entangle with CNOT: (|0⟩ + |1⟩)|0⟩ -> |00⟩ + |11⟩ + cx (q[0], q[1]); + + // Measure both qubits + result_0: u1 = mz(u1) q[0]; + result_1: u1 = mz(u1) q[1]; + + // result_0 and result_1 will always be equal due to entanglement + + return unit; +} diff --git a/exp/zlup/examples/ghz_state.qasm b/exp/zlup/examples/ghz_state.qasm new file mode 100644 index 000000000..43221feea --- /dev/null +++ b/exp/zlup/examples/ghz_state.qasm @@ -0,0 +1,11 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[4]; +creg c[4]; + +h q[0]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; +measure q[2] -> c[2]; +measure q[3] -> c[3]; diff --git a/exp/zlup/examples/ghz_state.zlp b/exp/zlup/examples/ghz_state.zlp new file mode 100644 index 000000000..07b0a4742 --- /dev/null +++ b/exp/zlup/examples/ghz_state.zlp @@ -0,0 +1,25 @@ +/// GHZ State Example +/// Creates an N-qubit Greenberger-Horne-Zeilinger state: (|000...0⟩ + |111...1⟩) / sqrt(2) +/// +/// This is a maximally entangled state of N qubits. +/// When measured, all qubits will give the same result. + +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Put first qubit in superposition + h q[0]; + + // Entangle each subsequent qubit with the first + cx (q[0], q[1]); + cx (q[0], q[2]); + cx (q[0], q[3]); + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + // All measurements will be the same: either all 0 or all 1 + + return unit; +} diff --git a/exp/zlup/examples/grover_2qubit.json b/exp/zlup/examples/grover_2qubit.json new file mode 100644 index 000000000..970bf7cc0 --- /dev/null +++ b/exp/zlup/examples/grover_2qubit.json @@ -0,0 +1,193 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 2 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 2 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + }, + { + "type": "BitRef", + "register": "c1", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} diff --git a/exp/zlup/examples/grover_2qubit.qasm b/exp/zlup/examples/grover_2qubit.qasm new file mode 100644 index 000000000..9f3338b24 --- /dev/null +++ b/exp/zlup/examples/grover_2qubit.qasm @@ -0,0 +1,20 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[2]; +creg c[2]; + +h q[0]; +h q[1]; +cz q[0], q[1]; +h q[0]; +h q[1]; +x q[0]; +x q[1]; +cz q[0], q[1]; +x q[0]; +x q[1]; +h q[0]; +h q[1]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; diff --git a/exp/zlup/examples/grover_2qubit.zlp b/exp/zlup/examples/grover_2qubit.zlp new file mode 100644 index 000000000..879a3baae --- /dev/null +++ b/exp/zlup/examples/grover_2qubit.zlp @@ -0,0 +1,45 @@ +/// Grover's Algorithm: 2-Qubit Search +/// Searches for the marked state |11⟩ among 4 possibilities. +/// +/// For N=4 states, a single Grover iteration gives optimal result. +/// The algorithm amplifies the amplitude of the marked state. + +pub fn main() -> unit { + q := qalloc(2); + pz q; + + // === Initialize superposition === + // Creates equal superposition: (|00⟩ + |01⟩ + |10⟩ + |11⟩) / 2 + h {q[0], q[1]}; + + // === Grover Iteration === + // For 2 qubits, one iteration is optimal + + // --- Oracle: Mark |11⟩ --- + // Applies phase flip to |11⟩: CZ gate + cz (q[0], q[1]); + + // --- Diffusion operator --- + // Reflects about the average amplitude + + // Apply H to both qubits + h {q[0], q[1]}; + + // Apply X to both qubits + x {q[0], q[1]}; + + // Apply CZ (controlled-Z) + cz (q[0], q[1]); + + // Apply X to both qubits + x {q[0], q[1]}; + + // Apply H to both qubits + h {q[0], q[1]}; + + // === Measure === + // Should find |11⟩ with high probability + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + + return unit; +} diff --git a/exp/zlup/examples/qft_3qubit.json b/exp/zlup/examples/qft_3qubit.json new file mode 100644 index 000000000..542aa09fc --- /dev/null +++ b/exp/zlup/examples/qft_3qubit.json @@ -0,0 +1,198 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 3 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 3 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "RZZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ], + "params": [ + { + "type": "VarExpr", + "name": "pi_2" + } + ] + }, + { + "type": "GateOp", + "gate": "RZZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ], + "params": [ + { + "type": "VarExpr", + "name": "pi_4" + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "RZZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ], + "params": [ + { + "type": "VarExpr", + "name": "pi_2" + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ] + }, + { + "type": "GateOp", + "gate": "SWAP", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + }, + { + "type": "BitRef", + "register": "c1", + "index": 0 + }, + { + "type": "BitRef", + "register": "c2", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} diff --git a/exp/zlup/examples/qft_3qubit.qasm b/exp/zlup/examples/qft_3qubit.qasm new file mode 100644 index 000000000..7f2379d4c --- /dev/null +++ b/exp/zlup/examples/qft_3qubit.qasm @@ -0,0 +1,18 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[3]; +creg c[3]; + +x q[0]; +x q[2]; +h q[0]; +rzz(pi_2) q[1], q[0]; +rzz(pi_4) q[2], q[0]; +h q[1]; +rzz(pi_2) q[2], q[1]; +h q[2]; +swap q[0], q[2]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; +measure q[2] -> c[2]; diff --git a/exp/zlup/examples/qft_3qubit.zlp b/exp/zlup/examples/qft_3qubit.zlp new file mode 100644 index 000000000..ee993f1f0 --- /dev/null +++ b/exp/zlup/examples/qft_3qubit.zlp @@ -0,0 +1,46 @@ +/// Quantum Fourier Transform: 3 Qubits +/// Implements the QFT circuit, a key component of many quantum algorithms +/// including Shor's factoring algorithm and quantum phase estimation. +/// +/// QFT transforms computational basis states to frequency basis. + +// Angle constants +pi_2: a64 = 1.5707963267948966; +pi_4: a64 = 0.7853981633974483; + +pub fn main() -> unit { + q := qalloc(3); + pz q; + + // Prepare an input state (e.g., |101⟩) + x q[0]; + x q[2]; + + // === QFT Circuit === + // Process qubits from most significant to least significant + + // Qubit 0 (MSB) + h q[0]; + // Controlled rotations from q[1] and q[2] + // CR(pi/2) controlled by q[1] + crz(pi_2) (q[1], q[0]); + // CR(pi/4) controlled by q[2] + crz(pi_4) (q[2], q[0]); + + // Qubit 1 + h q[1]; + // CR(pi/2) controlled by q[2] + crz(pi_2) (q[2], q[1]); + + // Qubit 2 (LSB) + h q[2]; + + // Swap qubits to get correct bit ordering + // QFT produces output in reversed order + swap (q[0], q[2]); + + // Measure in Fourier basis + results: [3]u1 = mz([3]u1) [q[0], q[1], q[2]]; + + return unit; +} diff --git a/exp/zlup/examples/simple_qec.json b/exp/zlup/examples/simple_qec.json new file mode 100644 index 000000000..5bfe5396d --- /dev/null +++ b/exp/zlup/examples/simple_qec.json @@ -0,0 +1,177 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 5 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "ancilla", + "capacity": 2, + "parent": "q" + }, + { + "type": "AllocatorDecl", + "name": "data", + "capacity": 3, + "parent": "q" + }, + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 5 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "TickStmt", + "label": "syndrome_round", + "body": [ + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "data", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "data", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "data", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "data", + "index": 2 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + } + ] + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + }, + { + "type": "BitRef", + "register": "c1", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} diff --git a/exp/zlup/examples/simple_qec.qasm b/exp/zlup/examples/simple_qec.qasm new file mode 100644 index 000000000..01c3998f5 --- /dev/null +++ b/exp/zlup/examples/simple_qec.qasm @@ -0,0 +1,17 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[5]; +creg c[2]; + +// tick syndrome_round +h q[0]; +cx q[0], q[0]; +cx q[0], q[1]; +h q[0]; +h q[1]; +cx q[1], q[1]; +cx q[1], q[2]; +h q[1]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; diff --git a/exp/zlup/examples/simple_qec.zlp b/exp/zlup/examples/simple_qec.zlp new file mode 100644 index 000000000..eb959bb1f --- /dev/null +++ b/exp/zlup/examples/simple_qec.zlp @@ -0,0 +1,54 @@ +/// Simple QEC Example: 3-Qubit Bit-Flip Code +/// Encodes a single logical qubit into 3 physical qubits. +/// Can detect and correct single bit-flip (X) errors. +/// +/// Encoding: |0⟩_L = |000⟩, |1⟩_L = |111⟩ +/// Stabilizers: Z₀Z₁, Z₁Z₂ + +pub fn main() -> unit { + // Allocate: 3 data qubits + 2 ancillas for syndrome measurement + mut q := qalloc(5); + data := q.child(3); // Data qubits [0, 1, 2] + ancilla := q.child(2); // Ancilla qubits for syndrome + + pz q; + + // === Encode logical |0⟩ === + // Starting from |000⟩, this is already the logical |0⟩ + // To encode |+⟩_L instead, uncomment: + // h data[0]; + // cx (data[0], data[1]); + // cx (data[0], data[2]); + + // === Syndrome Measurement Round === + tick syndrome_round { + // Measure Z₀Z₁ (parity of qubits 0 and 1) + h ancilla[0]; + cx (ancilla[0], data[0]); + cx (ancilla[0], data[1]); + h ancilla[0]; + + // Measure Z₁Z₂ (parity of qubits 1 and 2) + h ancilla[1]; + cx (ancilla[1], data[1]); + cx (ancilla[1], data[2]); + h ancilla[1]; + } + + // Read syndrome + s: [2]u1 = mz([2]u1) [ancilla[0], ancilla[1]]; + + // Decode syndrome to find error location: + // s = [0,0]: No error + // s = [1,0]: Error on qubit 0 + // s = [1,1]: Error on qubit 1 + // s = [0,1]: Error on qubit 2 + + // Syndrome interpretation: + // s = [0,0]: No error + // s = [1,0]: Error on qubit 0 + // s = [1,1]: Error on qubit 1 + // s = [0,1]: Error on qubit 2 + + return unit; +} diff --git a/exp/zlup/examples/teleportation.json b/exp/zlup/examples/teleportation.json new file mode 100644 index 000000000..faaf8576c --- /dev/null +++ b/exp/zlup/examples/teleportation.json @@ -0,0 +1,142 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 3 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 3 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + } + ], + "result_type": "u1" + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c1", + "index": 0 + } + ], + "result_type": "u1" + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c2", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} diff --git a/exp/zlup/examples/teleportation.qasm b/exp/zlup/examples/teleportation.qasm new file mode 100644 index 000000000..7fbe4ed69 --- /dev/null +++ b/exp/zlup/examples/teleportation.qasm @@ -0,0 +1,14 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[3]; +creg c[3]; + +h q[0]; +h q[1]; +cx q[1], q[2]; +cx q[0], q[1]; +h q[0]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; +measure q[2] -> c[2]; diff --git a/exp/zlup/examples/teleportation.zlp b/exp/zlup/examples/teleportation.zlp new file mode 100644 index 000000000..50bcdc06d --- /dev/null +++ b/exp/zlup/examples/teleportation.zlp @@ -0,0 +1,49 @@ +/// Quantum Teleportation Example +/// Teleports the state of qubit 0 to qubit 2 using a pre-shared Bell pair. +/// +/// Protocol: +/// 1. Alice and Bob share a Bell pair (qubits 1 and 2) +/// 2. Alice has the state to teleport (qubit 0) +/// 3. Alice performs Bell measurement on qubits 0 and 1 +/// 4. Alice sends classical bits to Bob +/// 5. Bob applies corrections based on measurement results + +pub fn main() -> unit { + q := qalloc(3); + pz q; + + // === Prepare the state to teleport on qubit 0 === + // For this example, we'll prepare |+⟩ = (|0⟩ + |1⟩) / sqrt(2) + h q[0]; + + // === Create Bell pair between qubits 1 and 2 === + // This is the "quantum channel" shared by Alice (q[1]) and Bob (q[2]) + h q[1]; + cx (q[1], q[2]); + + // === Alice's Bell measurement === + // Entangle the unknown state with her half of the Bell pair + cx (q[0], q[1]); + h q[0]; + + // Measure Alice's qubits + m0: u1 = mz(u1) q[0]; + m1: u1 = mz(u1) q[1]; + + // === Bob's corrections === + // Based on Alice's measurement results, Bob applies corrections + // Note: In a real implementation, these would be classically controlled + + // If m1 == 1, apply X gate + // if m1 == 1 { x q[2]; } + + // If m0 == 1, apply Z gate + // if m0 == 1 { z q[2]; } + + // After corrections, qubit 2 is in the original state of qubit 0 + + // Verify by measuring (should match original state statistics) + final_result: u1 = mz(u1) q[2]; + + return unit; +} diff --git a/exp/zlup/examples/test_lsp.zlp b/exp/zlup/examples/test_lsp.zlp new file mode 100644 index 000000000..cc01396f2 --- /dev/null +++ b/exp/zlup/examples/test_lsp.zlp @@ -0,0 +1,28 @@ +// Test file for zlups LSP +// Open this in Neovim to test diagnostics, hover, and completions + +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Apply Hadamard gates + h q[0]; + h q[1]; + + // Create entanglement + cx (q[0], q[2]); + cx (q[1], q[3]); + + // Measure qubits + r0: u1 = mz(u1) q[0]; + r1: u1 = mz(u1) q[1]; + + // This line has an error - undefined function + // Uncomment to test diagnostics: + // undefined_func(); + + return unit; +} + +// Test hover: put cursor on 'h', 'cx', 'qalloc', etc. +// Test completions: type 'q.' and press Ctrl+Space diff --git a/exp/zlup/ffi/zlup-ffi/Cargo.lock b/exp/zlup/ffi/zlup-ffi/Cargo.lock new file mode 100644 index 000000000..8e6af6540 --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "zlup-ffi" +version = "0.1.0" diff --git a/exp/zlup/ffi/zlup-ffi/Cargo.toml b/exp/zlup/ffi/zlup-ffi/Cargo.toml new file mode 100644 index 000000000..b89496b94 --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/Cargo.toml @@ -0,0 +1,23 @@ +# Keep this crate independent of parent workspace +[workspace] + +[package] +name = "zlup-ffi" +version = "0.1.0" +edition = "2021" +description = "FFI traits and types for integrating Rust code with Zlup" +license = "Apache-2.0" +repository = "https://github.com/PECOS-packages/PECOS" +keywords = ["quantum", "qec", "ffi", "decoder"] +categories = ["science", "api-bindings"] + +[features] +default = [] +# TODO: Enable proc macros for #[zlup_export] once zlup-ffi-macros crate exists +# macros = ["zlup-ffi-macros"] + +[dependencies] +# TODO: Add proc macro crate when implemented +# zlup-ffi-macros = { path = "../zlup-ffi-macros", optional = true } + +[dev-dependencies] diff --git a/exp/zlup/ffi/zlup-ffi/README.md b/exp/zlup/ffi/zlup-ffi/README.md new file mode 100644 index 000000000..5fcdd3e1d --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/README.md @@ -0,0 +1,77 @@ +# zlup-ffi + +FFI traits and types for integrating Rust code with Zlup. + +## Overview + +This crate provides the Rust side of Zlup's FFI story. Implement the provided traits to create decoders, noise models, and simulators that can be called from Zlup programs. + +## Quick Start + +```rust +use zlup_ffi::prelude::*; + +pub struct MyMwpmDecoder { + distance: usize, +} + +impl Decoder for MyMwpmDecoder { + type Syndrome = u64; + type Correction = u64; + + fn decode(&self, syndrome: u64) -> u64 { + // Your MWPM implementation here + 0 + } +} + +// Generate C ABI exports manually (or use #[zlup_export] with macros feature) +#[no_mangle] +pub extern "C" fn mwpm_new(distance: u32) -> *mut MyMwpmDecoder { + Box::into_raw(Box::new(MyMwpmDecoder { distance: distance as usize })) +} + +#[no_mangle] +pub extern "C" fn mwpm_decode(decoder: *const MyMwpmDecoder, syndrome: u64) -> u64 { + let decoder = unsafe { &*decoder }; + decoder.decode(syndrome) +} + +#[no_mangle] +pub extern "C" fn mwpm_free(decoder: *mut MyMwpmDecoder) { + if !decoder.is_null() { + unsafe { drop(Box::from_raw(decoder)); } + } +} +``` + +## Features + +- `macros` - Enable `#[zlup_export]` proc macro for automatic C ABI generation (planned) + +## Traits + +| Trait | Purpose | +|-------|---------| +| `Decoder` | Map syndromes to corrections | +| `NoiseModel` | Define custom noise channels for simulation | +| `Simulator` | Create custom quantum state simulators | +| `BatchDecoder` | Efficient batch decoding | +| `StreamingDecoder` | Temporal/streaming decoding | + +## Types + +| Type | Description | +|------|-------------| +| `QubitId` | Opaque qubit identifier | +| `GateType` | Enum of supported gate types | +| `PackedBits` | Efficient bit storage for syndromes | +| `FfiResult` | FFI-safe result type | + +## Documentation + +See the [Zlup Rust Integration Guide](../docs/rust-integration.md) for complete documentation. + +## License + +Apache-2.0 diff --git a/exp/zlup/ffi/zlup-ffi/src/lib.rs b/exp/zlup/ffi/zlup-ffi/src/lib.rs new file mode 100644 index 000000000..4a2164e6f --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/src/lib.rs @@ -0,0 +1,43 @@ +//! # zlup-ffi +//! +//! FFI traits and types for integrating Rust code with Zlup. +//! +//! This crate provides: +//! - Traits that decoders and simulation backends should implement +//! - FFI-safe types for crossing the Zlup/Rust boundary +//! - (With `macros` feature) `#[zlup_export]` proc macro for generating C ABI wrappers +//! +//! ## Quick Start +//! +//! ```rust,ignore +//! use zlup_ffi::prelude::*; +//! +//! pub struct MyDecoder { /* ... */ } +//! +//! impl Decoder for MyDecoder { +//! type Syndrome = u64; +//! type Correction = u64; +//! +//! fn decode(&self, syndrome: u64) -> u64 { +//! // Your decoding logic here +//! 0 +//! } +//! } +//! ``` +//! +//! See the [Zlup Rust Integration Guide](https://github.com/PECOS-packages/PECOS) for details. + +#![warn(missing_docs)] + +pub mod types; +pub mod traits; + +/// Prelude module - import everything commonly needed +pub mod prelude { + pub use crate::traits::*; + pub use crate::types::*; +} + +// TODO: Re-export proc macros when zlup-ffi-macros crate is implemented +// #[cfg(feature = "macros")] +// pub use zlup_ffi_macros::zlup_export; diff --git a/exp/zlup/ffi/zlup-ffi/src/traits.rs b/exp/zlup/ffi/zlup-ffi/src/traits.rs new file mode 100644 index 000000000..d02b6cbe1 --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/src/traits.rs @@ -0,0 +1,216 @@ +//! Core traits for Zlup FFI integration. +//! +//! Implement these traits to create decoders, noise models, and simulators +//! that can be called from Zlup. + +use crate::types::{CorrectionData, GateType, QubitId, SyndromeData}; + +/// A decoder that maps syndromes to corrections. +/// +/// This is the primary trait for implementing QEC decoders. +/// +/// # Example +/// +/// ```rust,ignore +/// use zlup_ffi::prelude::*; +/// +/// pub struct LookupDecoder { +/// table: Vec, +/// } +/// +/// impl Decoder for LookupDecoder { +/// type Syndrome = u64; +/// type Correction = u64; +/// +/// fn decode(&self, syndrome: u64) -> u64 { +/// self.table.get(syndrome as usize).copied().unwrap_or(0) +/// } +/// } +/// ``` +pub trait Decoder: Send + Sync { + /// The syndrome type (typically u64 or PackedBits). + type Syndrome: SyndromeData; + + /// The correction type (typically u64 or PackedBits). + type Correction: CorrectionData; + + /// Decode a syndrome into a correction. + /// + /// This is the core decoding operation. Given a syndrome (pattern of + /// stabilizer measurement outcomes), return the correction to apply. + fn decode(&self, syndrome: Self::Syndrome) -> Self::Correction; + + /// Decode with soft information (for ML decoders). + /// + /// Some decoders (e.g., neural network decoders) can use soft information + /// like measurement probabilities. The default implementation ignores + /// soft info and calls the standard `decode`. + fn decode_soft(&self, syndrome: Self::Syndrome, _soft_info: &[f32]) -> Self::Correction { + self.decode(syndrome) + } + + /// Reset decoder state between shots. + /// + /// Some decoders maintain state (e.g., for temporal decoding). + /// Call this between independent decoding problems. + fn reset(&mut self) {} + + /// Get the code distance this decoder is configured for. + fn distance(&self) -> Option { + None + } + + /// Get the number of syndrome bits expected. + fn syndrome_bits(&self) -> Option { + None + } +} + +/// A noise model for quantum simulation. +/// +/// Implement this trait to define custom noise channels. +pub trait NoiseModel: Send + Sync { + /// Apply noise after a gate operation. + /// + /// Called after each gate in the circuit. The noise model can + /// inject errors based on the gate type and affected qubits. + fn apply_gate_noise( + &self, + gate: GateType, + qubits: &[QubitId], + rng: &mut dyn RngCore, + ); + + /// Apply measurement noise. + /// + /// Returns true if the measurement outcome should be flipped. + fn apply_measurement_noise(&self, qubit: QubitId, rng: &mut dyn RngCore) -> bool; + + /// Apply idle noise for a time step. + /// + /// Called for qubits that are idle during a tick. + fn apply_idle_noise(&self, qubits: &[QubitId], rng: &mut dyn RngCore); + + /// Get the error rate for a specific gate type. + fn gate_error_rate(&self, gate: GateType) -> f64 { + let _ = gate; + 0.0 + } + + /// Get the measurement error rate. + fn measurement_error_rate(&self) -> f64 { + 0.0 + } +} + +/// A quantum state simulator. +/// +/// Implement this trait to create custom simulation backends. +pub trait Simulator: Send + Sync { + /// Apply a single-qubit gate. + fn apply_single_qubit_gate(&mut self, gate: GateType, qubit: QubitId); + + /// Apply a two-qubit gate. + fn apply_two_qubit_gate(&mut self, gate: GateType, control: QubitId, target: QubitId); + + /// Apply a three-qubit gate. + fn apply_three_qubit_gate( + &mut self, + gate: GateType, + q0: QubitId, + q1: QubitId, + q2: QubitId, + ); + + /// Measure a qubit in the Z basis. + /// + /// Returns the measurement outcome (0 or 1). + fn measure_z(&mut self, qubit: QubitId) -> bool; + + /// Reset a qubit to |0⟩. + fn reset(&mut self, qubit: QubitId); + + /// Initialize the simulator with a given number of qubits. + fn initialize(&mut self, num_qubits: usize); + + /// Get the current number of qubits. + fn num_qubits(&self) -> usize; +} + +/// Minimal RNG trait for noise models. +/// +/// This is a simplified version of `rand::RngCore` to avoid +/// requiring the full rand crate as a dependency. +pub trait RngCore { + /// Generate a random u64. + fn next_u64(&mut self) -> u64; + + /// Generate a random f64 in [0, 1). + fn gen_f64(&mut self) -> f64 { + // Standard conversion from u64 to [0, 1) + (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64) + } + + /// Generate a random bool with given probability of true. + fn gen_bool(&mut self, probability: f64) -> bool { + self.gen_f64() < probability + } +} + +/// A simple XorShift64 RNG for when you don't need cryptographic randomness. +pub struct XorShift64 { + state: u64, +} + +impl XorShift64 { + /// Create a new RNG with the given seed. + pub fn new(seed: u64) -> Self { + // Ensure non-zero state + Self { + state: if seed == 0 { 1 } else { seed }, + } + } +} + +impl RngCore for XorShift64 { + fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.state = x; + x + } +} + +/// Extension trait for decoders that support batch decoding. +pub trait BatchDecoder: Decoder { + /// Decode multiple syndromes at once. + /// + /// This can be more efficient than calling `decode` repeatedly + /// due to better cache utilization or parallelization. + fn decode_batch( + &self, + syndromes: &[Self::Syndrome], + corrections: &mut [Self::Correction], + ) { + assert_eq!(syndromes.len(), corrections.len()); + for (syn, cor) in syndromes.iter().zip(corrections.iter_mut()) { + *cor = self.decode(*syn); + } + } +} + +/// Extension trait for decoders that support streaming/temporal decoding. +pub trait StreamingDecoder: Decoder { + /// Feed a syndrome from a new round. + /// + /// For temporal decoders that look at syndrome history. + fn feed_round(&mut self, syndrome: Self::Syndrome); + + /// Get the current correction estimate. + fn current_correction(&self) -> Self::Correction; + + /// Commit the current state (e.g., after a logical measurement). + fn commit(&mut self) -> Self::Correction; +} diff --git a/exp/zlup/ffi/zlup-ffi/src/types.rs b/exp/zlup/ffi/zlup-ffi/src/types.rs new file mode 100644 index 000000000..ed67204db --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/src/types.rs @@ -0,0 +1,602 @@ +//! FFI-safe types for Zlup/Rust interop. +//! +//! These types are designed to cross the FFI boundary safely and map +//! directly to Zlup types. + +use std::marker::PhantomData; + +/// A qubit identifier. +/// +/// This is an opaque handle that identifies a qubit in the quantum state. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct QubitId(pub u32); + +impl QubitId { + /// Create a new qubit ID. + pub fn new(id: u32) -> Self { + Self(id) + } + + /// Get the raw ID value. + pub fn raw(&self) -> u32 { + self.0 + } +} + +/// Gate types supported by Zlup. +/// +/// This enum maps directly to Zlup's gate operations. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GateType { + // Single-qubit Pauli gates + /// Pauli X gate + X, + /// Pauli Y gate + Y, + /// Pauli Z gate + Z, + + // Single-qubit Clifford gates + /// Hadamard gate + H, + /// S gate (sqrt Z) + S, + /// S-dagger gate + Sdg, + /// T gate (fourth root Z) + T, + /// T-dagger gate + Tdg, + + // Square root gates + /// sqrt(X) gate + Sx, + /// sqrt(Y) gate + Sy, + /// sqrt(Z) gate (same as S) + Sz, + + // Rotation gates (angle in radians) + /// Rotation around X axis + Rx(f64), + /// Rotation around Y axis + Ry(f64), + /// Rotation around Z axis + Rz(f64), + + // Two-qubit gates + /// Controlled-X (CNOT) + Cx, + /// Controlled-Y + Cy, + /// Controlled-Z + Cz, + /// Controlled-H + Ch, + + // Swap gates + /// SWAP gate + Swap, + /// iSWAP gate + Iswap, + + // Ising gates + /// sqrt(XX) + Sxx, + /// sqrt(YY) + Syy, + /// sqrt(ZZ) + Szz, + + // Parameterized two-qubit gates + /// ZZ rotation + Rzz(f64), + /// XX rotation + Rxx(f64), + /// YY rotation + Ryy(f64), + + // Three-qubit gates + /// Toffoli (CCX) + Ccx, +} + +// ============================================================================= +// Angle64 - Fixed-point angle representation +// ============================================================================= + +/// A 64-bit fixed-point angle representation. +/// +/// Angles are stored as fractions of a full turn using fixed-point arithmetic. +/// The internal representation uses a u64 where [0, 2^64) maps to [0, 1) turns. +/// This provides exact representation for all dyadic fractions (denominators +/// that are powers of 2), which covers all common quantum gate angles: +/// +/// - 1/2 turn (π rad) = 2^63 +/// - 1/4 turn (π/2 rad) = 2^62 +/// - 1/8 turn (π/4 rad, T-gate) = 2^61 +/// - etc. +/// +/// # Design Rationale +/// +/// This representation is compatible with PECOS's angle handling and avoids +/// floating-point precision issues that can cause bugs in quantum circuits +/// (similar to the Mars Climate Orbiter unit conversion bug). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Angle64 { + /// Internal fixed-point representation. + /// The full range [0, 2^64) represents [0, 1) turns. + raw: u64, +} + +impl Angle64 { + /// The constant 2^64 as f64 for conversions. + const SCALE: f64 = (1u64 << 63) as f64 * 2.0; + + /// Zero angle. + pub const ZERO: Angle64 = Angle64 { raw: 0 }; + + /// One full turn (wraps to zero in modular arithmetic). + pub const FULL_TURN: Angle64 = Angle64 { raw: 0 }; + + /// Half turn (π radians, 180°). + pub const HALF_TURN: Angle64 = Angle64 { raw: 1 << 63 }; + + /// Quarter turn (π/2 radians, 90°). + pub const QUARTER_TURN: Angle64 = Angle64 { raw: 1 << 62 }; + + /// Eighth turn (π/4 radians, 45°, T-gate angle). + pub const EIGHTH_TURN: Angle64 = Angle64 { raw: 1 << 61 }; + + /// Create an angle from a fraction of a turn. + /// + /// The value should be in [0, 1) for angles less than a full turn, + /// but values outside this range will wrap correctly. + /// + /// # Example + /// ``` + /// use zlup_ffi::types::Angle64; + /// let quarter = Angle64::from_turns(0.25); + /// assert_eq!(quarter, Angle64::QUARTER_TURN); + /// ``` + pub fn from_turns(turns: f64) -> Self { + // Normalize to [0, 1) range + let normalized = turns.rem_euclid(1.0); + let raw = (normalized * Self::SCALE) as u64; + Self { raw } + } + + /// Create an angle from radians. + /// + /// # Example + /// ``` + /// use zlup_ffi::types::Angle64; + /// use std::f64::consts::PI; + /// let quarter = Angle64::from_radians(PI / 2.0); + /// assert_eq!(quarter, Angle64::QUARTER_TURN); + /// ``` + pub fn from_radians(radians: f64) -> Self { + Self::from_turns(radians / (2.0 * std::f64::consts::PI)) + } + + /// Create an angle from an exact fraction of a turn. + /// + /// This method provides exact representation for dyadic fractions + /// (where the denominator is a power of 2). + /// + /// # Example + /// ``` + /// use zlup_ffi::types::Angle64; + /// let quarter = Angle64::from_turn_fraction(1, 4); + /// assert_eq!(quarter, Angle64::QUARTER_TURN); + /// ``` + pub fn from_turn_fraction(numerator: u64, denominator: u64) -> Self { + if denominator == 0 { + return Self::ZERO; + } + // For exact representation, we compute: (numerator * 2^64) / denominator + // Using 128-bit arithmetic for precision + let num_scaled = (numerator as u128) << 64; + let raw = (num_scaled / denominator as u128) as u64; + Self { raw } + } + + /// Convert to turns (fraction of a full rotation). + pub fn to_turns(&self) -> f64 { + self.raw as f64 / Self::SCALE + } + + /// Convert to radians. + pub fn to_radians(&self) -> f64 { + self.to_turns() * 2.0 * std::f64::consts::PI + } + + /// Get the raw fixed-point value. + pub fn raw(&self) -> u64 { + self.raw + } + + /// Create from raw fixed-point value. + pub fn from_raw(raw: u64) -> Self { + Self { raw } + } + + /// Add two angles (wraps at full turn). + pub fn add(self, other: Self) -> Self { + Self { + raw: self.raw.wrapping_add(other.raw), + } + } + + /// Subtract two angles (wraps at full turn). + pub fn sub(self, other: Self) -> Self { + Self { + raw: self.raw.wrapping_sub(other.raw), + } + } + + /// Negate the angle. + pub fn neg(self) -> Self { + Self { + raw: self.raw.wrapping_neg(), + } + } + + /// Multiply the angle by an integer. + pub fn mul(self, n: u64) -> Self { + Self { + raw: self.raw.wrapping_mul(n), + } + } + + /// Divide the angle by an integer. + pub fn div(self, n: u64) -> Self { + if n == 0 { + return Self::ZERO; + } + Self { raw: self.raw / n } + } + + /// Check if this is exactly zero. + pub fn is_zero(&self) -> bool { + self.raw == 0 + } + + /// Check if this is exactly a half turn. + pub fn is_half_turn(&self) -> bool { + self.raw == Self::HALF_TURN.raw + } + + /// Check if this is a Clifford angle (multiple of 1/4 turn). + pub fn is_clifford(&self) -> bool { + // Clifford angles are multiples of 1/4 turn + // In our representation, that means the lower 62 bits are zero + (self.raw & ((1 << 62) - 1)) == 0 + } +} + +impl std::ops::Add for Angle64 { + type Output = Self; + fn add(self, other: Self) -> Self { + self.add(other) + } +} + +impl std::ops::Sub for Angle64 { + type Output = Self; + fn sub(self, other: Self) -> Self { + Self::sub(self, other) + } +} + +impl std::ops::Neg for Angle64 { + type Output = Self; + fn neg(self) -> Self { + Self::neg(self) + } +} + +impl std::fmt::Display for Angle64 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Try to display as a nice fraction if possible + let turns = self.to_turns(); + if turns == 0.0 { + write!(f, "0 turns") + } else if turns == 0.5 { + write!(f, "1/2 turns") + } else if turns == 0.25 { + write!(f, "1/4 turns") + } else if turns == 0.125 { + write!(f, "1/8 turns") + } else if turns == 0.75 { + write!(f, "3/4 turns") + } else { + write!(f, "{:.6} turns", turns) + } + } +} + +/// Packed bit representation for syndromes and corrections (64 bits). +/// +/// For larger syndromes, use `Syndrome128` or `Syndrome256`. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Syndrome64 { + data: u64, +} + +impl Syndrome64 { + /// Create a new Syndrome64 with all zeros. + pub fn zeros() -> Self { + Self { data: 0 } + } + + /// Create from a u64. + pub fn from_u64(value: u64) -> Self { + Self { data: value } + } + + /// Get a single bit. + pub fn get(&self, index: usize) -> bool { + if index >= 64 { + return false; + } + (self.data >> index) & 1 == 1 + } + + /// Set a single bit. + pub fn set(&mut self, index: usize, value: bool) { + if index >= 64 { + return; + } + if value { + self.data |= 1 << index; + } else { + self.data &= !(1 << index); + } + } + + /// Get the number of bits (always 64). + pub fn len(&self) -> usize { + 64 + } + + /// Check if empty (never true for fixed-size). + pub fn is_empty(&self) -> bool { + false + } + + /// Convert to u64. + pub fn to_u64(&self) -> u64 { + self.data + } + + /// Count the number of set bits (popcount). + pub fn popcount(&self) -> usize { + self.data.count_ones() as usize + } + + /// Compute parity (XOR of all bits). + pub fn parity(&self) -> bool { + self.data.count_ones() % 2 == 1 + } +} + +/// 128-bit syndrome storage. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Syndrome128 { + low: u64, + high: u64, +} + +impl Syndrome128 { + /// Create a new Syndrome128 with all zeros. + pub fn zeros() -> Self { + Self { low: 0, high: 0 } + } + + /// Get a single bit. + pub fn get(&self, index: usize) -> bool { + if index >= 128 { + return false; + } + if index < 64 { + (self.low >> index) & 1 == 1 + } else { + (self.high >> (index - 64)) & 1 == 1 + } + } + + /// Set a single bit. + pub fn set(&mut self, index: usize, value: bool) { + if index >= 128 { + return; + } + if index < 64 { + if value { + self.low |= 1 << index; + } else { + self.low &= !(1 << index); + } + } else { + let bit = index - 64; + if value { + self.high |= 1 << bit; + } else { + self.high &= !(1 << bit); + } + } + } + + /// Count the number of set bits (popcount). + pub fn popcount(&self) -> usize { + (self.low.count_ones() + self.high.count_ones()) as usize + } +} + +/// 256-bit syndrome storage. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Syndrome256 { + words: [u64; 4], +} + +impl Syndrome256 { + /// Create a new Syndrome256 with all zeros. + pub fn zeros() -> Self { + Self { words: [0; 4] } + } + + /// Get a single bit. + pub fn get(&self, index: usize) -> bool { + if index >= 256 { + return false; + } + let word = index / 64; + let bit = index % 64; + (self.words[word] >> bit) & 1 == 1 + } + + /// Set a single bit. + pub fn set(&mut self, index: usize, value: bool) { + if index >= 256 { + return; + } + let word = index / 64; + let bit = index % 64; + if value { + self.words[word] |= 1 << bit; + } else { + self.words[word] &= !(1 << bit); + } + } + + /// Count the number of set bits (popcount). + pub fn popcount(&self) -> usize { + self.words.iter().map(|w| w.count_ones() as usize).sum() + } +} + +/// FFI-safe result type. +/// +/// Use this for functions that can fail across FFI boundaries. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct FfiResult { + /// Whether the operation succeeded. + pub ok: bool, + /// The value (only valid if ok is true). + pub value: T, + /// Error code (only valid if ok is false). + pub error_code: u32, +} + +impl FfiResult { + /// Create a success result. + pub fn ok(value: T) -> Self { + Self { + ok: true, + value, + error_code: 0, + } + } + + /// Create an error result. + pub fn err(code: u32) -> Self { + Self { + ok: false, + value: T::default(), + error_code: code, + } + } +} + +impl FfiResult { + /// Convert to a Rust Result. + pub fn into_result(self) -> Result { + if self.ok { + Ok(self.value) + } else { + Err(self.error_code) + } + } +} + +/// Common FFI error codes. +pub mod error_codes { + /// Success (no error). + pub const SUCCESS: u32 = 0; + /// Null pointer passed. + pub const NULL_POINTER: u32 = 1; + /// Invalid argument. + pub const INVALID_ARGUMENT: u32 = 2; + /// Out of memory. + pub const OUT_OF_MEMORY: u32 = 3; + /// Decoder failed. + pub const DECODE_FAILED: u32 = 4; + /// Internal error. + pub const INTERNAL_ERROR: u32 = 5; +} + +/// Marker trait for types that are safe to use as syndrome data. +pub trait SyndromeData: Copy + Send + Sync {} + +impl SyndromeData for u8 {} +impl SyndromeData for u16 {} +impl SyndromeData for u32 {} +impl SyndromeData for u64 {} +impl SyndromeData for Syndrome64 {} +impl SyndromeData for Syndrome128 {} +impl SyndromeData for Syndrome256 {} + +/// Marker trait for types that are safe to use as correction data. +pub trait CorrectionData: Copy + Send + Sync {} + +impl CorrectionData for u8 {} +impl CorrectionData for u16 {} +impl CorrectionData for u32 {} +impl CorrectionData for u64 {} +impl CorrectionData for Syndrome64 {} +impl CorrectionData for Syndrome128 {} +impl CorrectionData for Syndrome256 {} + +/// A slice of qubits for FFI. +#[repr(C)] +pub struct QubitSlice<'a> { + ptr: *const QubitId, + len: usize, + _marker: PhantomData<&'a [QubitId]>, +} + +impl<'a> QubitSlice<'a> { + /// Create from a Rust slice. + pub fn from_slice(slice: &'a [QubitId]) -> Self { + Self { + ptr: slice.as_ptr(), + len: slice.len(), + _marker: PhantomData, + } + } + + /// Convert back to a Rust slice. + /// + /// # Safety + /// The pointer must still be valid. + pub unsafe fn as_slice(&self) -> &'a [QubitId] { + std::slice::from_raw_parts(self.ptr, self.len) + } + + /// Get the length. + pub fn len(&self) -> usize { + self.len + } + + /// Check if empty. + pub fn is_empty(&self) -> bool { + self.len == 0 + } +} diff --git a/exp/zlup/fuzz/Cargo.lock b/exp/zlup/fuzz/Cargo.lock new file mode 100644 index 000000000..7ddee9bc1 --- /dev/null +++ b/exp/zlup/fuzz/Cargo.lock @@ -0,0 +1,967 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zlup" +version = "0.1.0" +dependencies = [ + "env_logger", + "log", + "miette", + "pest", + "pest_derive", + "serde", + "serde_json", + "smol_str", + "thiserror 1.0.69", + "toml", +] + +[[package]] +name = "zlup-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "libfuzzer-sys", + "zlup", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/exp/zlup/fuzz/Cargo.toml b/exp/zlup/fuzz/Cargo.toml new file mode 100644 index 000000000..11aad2ce3 --- /dev/null +++ b/exp/zlup/fuzz/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "zlup-fuzz" +version = "0.0.0" +authors = ["Automatically generated"] +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } + +[dependencies.zlup] +path = ".." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[profile.release] +debug = 1 + +[[bin]] +name = "fuzz_parser" +path = "fuzz_targets/fuzz_parser.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_semantic" +path = "fuzz_targets/fuzz_semantic.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_comptime" +path = "fuzz_targets/fuzz_comptime.rs" +test = false +doc = false +bench = false diff --git a/exp/zlup/fuzz/fuzz_targets/fuzz_comptime.rs b/exp/zlup/fuzz/fuzz_targets/fuzz_comptime.rs new file mode 100644 index 000000000..7ca02256c --- /dev/null +++ b/exp/zlup/fuzz/fuzz_targets/fuzz_comptime.rs @@ -0,0 +1,40 @@ +//! Fuzz target for the Zlup compile-time evaluator. +//! +//! This target fuzzes the comptime evaluator with arbitrary expressions to find: +//! - Panics during evaluation +//! - Overflow issues not caught by checked arithmetic +//! - Infinite recursion +//! +//! Run with: +//! ```bash +//! cargo +nightly fuzz run fuzz_comptime +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zlup::comptime::ComptimeEvaluator; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, skipping invalid UTF-8 + if let Ok(source) = std::str::from_utf8(data) { + // Wrap in a simple expression context + let wrapped = format!("x := {};", source); + + // Try to parse as a binding with an expression + if let Ok(program) = zlup::parse(&wrapped) { + // Try to evaluate any expressions in the program + let mut evaluator = ComptimeEvaluator::new(); + + // Extract expressions from bindings and try to evaluate them + for decl in &program.declarations { + if let zlup::ast::TopLevelDecl::Binding(binding) = decl { + if let Some(expr) = &binding.value { + // The evaluator should never panic + let _ = evaluator.eval_expr(expr); + } + } + } + } + } +}); diff --git a/exp/zlup/fuzz/fuzz_targets/fuzz_parser.rs b/exp/zlup/fuzz/fuzz_targets/fuzz_parser.rs new file mode 100644 index 000000000..acff2864d --- /dev/null +++ b/exp/zlup/fuzz/fuzz_targets/fuzz_parser.rs @@ -0,0 +1,23 @@ +//! Fuzz target for the Zlup parser. +//! +//! This target fuzzes the parser with arbitrary byte sequences to find: +//! - Panics or crashes +//! - Infinite loops (detected via timeout) +//! - Memory issues +//! +//! Run with: +//! ```bash +//! cargo +nightly fuzz run fuzz_parser +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, skipping invalid UTF-8 + if let Ok(source) = std::str::from_utf8(data) { + // The parser should never panic on any input + let _ = zlup::parse(source); + } +}); diff --git a/exp/zlup/fuzz/fuzz_targets/fuzz_semantic.rs b/exp/zlup/fuzz/fuzz_targets/fuzz_semantic.rs new file mode 100644 index 000000000..a2f24cbd6 --- /dev/null +++ b/exp/zlup/fuzz/fuzz_targets/fuzz_semantic.rs @@ -0,0 +1,28 @@ +//! Fuzz target for the Zlup semantic analyzer. +//! +//! This target fuzzes the semantic analyzer with arbitrary programs to find: +//! - Panics or crashes during type checking +//! - Infinite loops in type inference +//! - Memory issues in symbol table operations +//! +//! Run with: +//! ```bash +//! cargo +nightly fuzz run fuzz_semantic +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zlup::semantic::SemanticAnalyzer; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, skipping invalid UTF-8 + if let Ok(source) = std::str::from_utf8(data) { + // First try to parse - skip if parsing fails + if let Ok(program) = zlup::parse(source) { + // The semantic analyzer should never panic on any valid AST + let mut analyzer = SemanticAnalyzer::new(); + let _ = analyzer.analyze(&program); + } + } +}); diff --git a/exp/zlup/fuzz/osv-scanner.toml b/exp/zlup/fuzz/osv-scanner.toml new file mode 100644 index 000000000..a220564c9 --- /dev/null +++ b/exp/zlup/fuzz/osv-scanner.toml @@ -0,0 +1,11 @@ +# OSV-Scanner configuration for the zlup fuzz harness (config discovery is +# per-directory: the root osv-scanner.toml does not apply to this Cargo.lock). +# Same ignore-list policy as the root osv-scanner.toml: only entries we cannot +# fix from this repo, each documenting crate@version, chain, and upstream owner. + +[[IgnoredVulns]] +id = "RUSTSEC-2026-0173" +# proc-macro-error2@2.0.1 -- unmaintained proc-macro (fork of proc-macro-error). +# Chain: zlup-fuzz -> env_logger -> jiff -> defmt -> defmt-macros -> proc-macro-error2. +# Upstream owners: https://github.com/BurntSushi/jiff and https://github.com/knurling-rs/defmt. +reason = "Transitive via the fuzz harness's env_logger -> jiff -> defmt chain; compile-time proc-macro only, no runtime impact." diff --git a/exp/zlup/mkdocs.yml b/exp/zlup/mkdocs.yml new file mode 100644 index 000000000..5b4697df8 --- /dev/null +++ b/exp/zlup/mkdocs.yml @@ -0,0 +1,73 @@ +site_name: Zlup Documentation +site_description: Quantum programming language with Zig semantics and Rust/Python syntax +site_url: https://pecos.dev/zlup/ + +repo_name: PECOS/zlup +repo_url: https://github.com/PECOS-packages/PECOS + +theme: + name: material + palette: + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.highlight + - content.code.copy + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + extend_pygments_lang: + - name: zlup + lang: zig + - name: zlup_fragment + lang: zig + - name: zlup_nocheck + lang: zig + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - admonition + - pymdownx.details + - tables + - toc: + permalink: true + +nav: + - Home: index.md + - Getting Started: + - Tutorial: tutorial.md + - CLI Reference: cli.md + - IDE Setup: ide-setup.md + - Reference: + - Language Syntax: syntax.md + - Standard Library: stdlib.md + - Error Messages: errors.md + - Advanced: + - Design Philosophy: design.md + - Rust Integration: rust-integration.md + - Contributing: + - Development Notes: dev-notes.md + - Future: + - Build System: future/build-system.md + - Guppy Compatibility: future/guppy-compat.md + - Stdlib Design: future/stdlib-design.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/PECOS-packages/PECOS diff --git a/exp/zlup/pyproject.toml b/exp/zlup/pyproject.toml new file mode 100644 index 000000000..c76e5bef0 --- /dev/null +++ b/exp/zlup/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "zlup-docs" +version = "0.1.0" +description = "Zlup documentation" +requires-python = ">=3.10" +dependencies = [ + "mkdocs>=1.6", + "mkdocs-material>=9.5", +] + +[project.optional-dependencies] +dev = [ + "mkdocs-minify-plugin", +] diff --git a/exp/zlup/scripts/check_docs.py b/exp/zlup/scripts/check_docs.py new file mode 100644 index 000000000..38182fa5e --- /dev/null +++ b/exp/zlup/scripts/check_docs.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Check Zlup code snippets in documentation for syntax/semantic errors. + +Extracts fenced code blocks from docs/**/*.md and validates them: + - ``zlup`` → zlup check (parse + semantic) + - ``zlup_fragment`` → zlup parse (syntax only), wrapped in fn + - ``zlup_nocheck`` → skipped + +Non-Zlup fences (bash, rust, zig, json, etc.) are ignored. + +Usage: + python3 scripts/check_docs.py [--verbose] +""" + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +ZLUP_TAGS = {"zlup", "zlup_fragment", "zlup_nocheck"} + +# Heuristic: lines starting with these indicate a complete (top-level) program +_TOPLEVEL_PREFIXES = ( + "fn ", + "pub ", + "inline fn ", + "@attr", + "gate ", + "declare gate", + "test ", + "extern fn", +) + +_TOPLEVEL_CONTAINS = ( + ":= struct", + ":= enum", + ":= error", + ":= fault", + ":= union", + ":= @import", +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def find_zlup_binary() -> str: + """Locate the zlup binary (workspace target/debug or release).""" + script_dir = Path(__file__).resolve().parent + zlup_dir = script_dir.parent # exp/zlup + + # Walk up to find workspace root (contains target/) + candidate = zlup_dir + for _ in range(5): + target = candidate / "target" / "debug" / "zlup" + if target.is_file(): + return str(target) + target_rel = candidate / "target" / "release" / "zlup" + if target_rel.is_file(): + return str(target_rel) + candidate = candidate.parent + + # Fallback: hope it's on PATH + return "zlup" + + +def is_complete_program(source: str) -> bool: + """Heuristic: does this snippet look like a complete top-level program?""" + for line in source.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith(("//", "///")): + continue + # First non-comment line + for prefix in _TOPLEVEL_PREFIXES: + if stripped.startswith(prefix): + return True + return any(pattern in stripped for pattern in _TOPLEVEL_CONTAINS) + return False + + +def wrap_fragment(source: str) -> str: + """Wrap a code fragment in a function body for parsing.""" + # Replace `{ ... }` placeholder bodies with `{ }` so they parse + wrapped = re.sub(r"\{\s*\.\.\.\s*\}", "{ }", source) + # Also replace bare `// ...` comment-only placeholders + wrapped = re.sub(r"//\s*\.\.\.", "// placeholder", wrapped) + return f"fn __snippet__() -> unit {{\n{wrapped}\nreturn;\n}}" + + +# --------------------------------------------------------------------------- +# Extraction +# --------------------------------------------------------------------------- + +FENCE_RE = re.compile(r"^```(\w+)?\s*$") + + +def extract_blocks(filepath: str) -> list[dict]: + """Extract fenced code blocks from a markdown file.""" + blocks = [] + with Path(filepath).open() as f: + lines = f.readlines() + + in_block = False + tag = None + start_line = 0 + block_lines: list[str] = [] + + for i, line in enumerate(lines, 1): + if not in_block: + m = FENCE_RE.match(line) + if m: + tag = m.group(1) or "" + in_block = True + start_line = i + block_lines = [] + else: + if line.rstrip() == "```": + blocks.append( + { + "file": filepath, + "line": start_line, + "tag": tag, + "source": "".join(block_lines), + }, + ) + in_block = False + else: + block_lines.append(line) + + return blocks + + +# --------------------------------------------------------------------------- +# Checking +# --------------------------------------------------------------------------- + + +def run_zlup(binary: str, cmd: str, source: str) -> tuple[bool, str]: + """Run zlup check/parse on source via stdin. Returns (ok, output).""" + try: + result = subprocess.run( + [binary, cmd, "-"], + input=source, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except subprocess.TimeoutExpired: + return False, "TIMEOUT" + except FileNotFoundError: + return False, f"zlup binary not found: {binary}" + else: + output = (result.stdout + result.stderr).strip() + return result.returncode == 0, output + + +def check_block(binary: str, block: dict) -> tuple[str, str]: + """Check a single code block. Returns (status, detail). + + status is one of: "OK", "FAIL", "SKIP" + """ + tag = block["tag"] + source = block["source"] + + if tag not in ZLUP_TAGS: + return "SKIP", "non-zlup fence" + + if tag == "zlup_nocheck": + return "SKIP", "nocheck" + + if not source.strip(): + return "SKIP", "empty block" + + if tag == "zlup": + # Full check (parse + semantic) + ok, output = run_zlup(binary, "check", source) + return ("OK" if ok else "FAIL"), output + + if tag == "zlup_fragment": + # Wrap and parse-only + wrapped = wrap_fragment(source) + ok, output = run_zlup(binary, "parse", wrapped) + return ("OK" if ok else "FAIL"), output + + return "SKIP", f"unknown tag: {tag}" + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--verbose", "-v", action="store_true", help="Show result for each snippet") + args = parser.parse_args() + + # Find docs directory relative to this script + script_dir = Path(__file__).resolve().parent + docs_dir = script_dir.parent / "docs" + + if not docs_dir.is_dir(): + print(f"Error: docs directory not found at {docs_dir}", file=sys.stderr) + sys.exit(1) + + binary = find_zlup_binary() + + # Quick check that the binary works + ok, _ = run_zlup(binary, "parse", "fn test() -> unit { return; }") + if not ok: + print(f"Error: zlup binary not working ({binary})", file=sys.stderr) + print("Run 'just build' first.", file=sys.stderr) + sys.exit(1) + + # Collect all markdown files + md_files = sorted(str(p) for p in docs_dir.rglob("*.md")) + if not md_files: + print("No markdown files found in docs/", file=sys.stderr) + sys.exit(1) + + # Process + counts = {"OK": 0, "FAIL": 0, "SKIP": 0} + failures: list[dict] = [] + + for md_file in md_files: + blocks = extract_blocks(md_file) + for block in blocks: + status, detail = check_block(binary, block) + counts[status] += 1 + + rel = os.path.relpath(block["file"], docs_dir.parent) + + if args.verbose: + tag_info = f"[{block['tag']}]" + if status == "FAIL": + print(f" FAIL {rel}:{block['line']} {tag_info}") + # Show first few lines of error + for err_line in detail.splitlines()[:6]: + print(f" {err_line}") + elif status == "SKIP": + print(f" SKIP {rel}:{block['line']} {tag_info} ({detail})") + else: + print(f" OK {rel}:{block['line']} {tag_info}") + + if status == "FAIL": + failures.append( + { + "file": rel, + "line": block["line"], + "tag": block["tag"], + "detail": detail, + }, + ) + + # Summary + total = counts["OK"] + counts["FAIL"] + counts["SKIP"] + print() + print(f"check-docs: {total} snippets — {counts['OK']} ok, {counts['FAIL']} failed, {counts['SKIP']} skipped") + + if failures: + print() + print("Failures:") + for f in failures: + print(f" {f['file']}:{f['line']} [{f['tag']}]") + for err_line in f["detail"].splitlines()[:4]: + print(f" {err_line}") + print() + sys.exit(1) + else: + print("All checks passed.") + + +if __name__ == "__main__": + main() diff --git a/exp/zlup/src/analysis.rs b/exp/zlup/src/analysis.rs new file mode 100644 index 000000000..81d15359a --- /dev/null +++ b/exp/zlup/src/analysis.rs @@ -0,0 +1,1352 @@ +//! Parallelism analysis passes for Zlup AST. +//! +//! This module provides analysis passes that determine what operations can +//! execute in parallel. The analysis is constraint-based: parallelism follows +//! directly from allocator ownership and explicit operation targets. +//! +//! ## Design Philosophy +//! +//! > **Note:** Zlup is an experimental toy language for exploring quantum programming +//! > language design. This analysis is exploratory, not production-ready. +//! +//! Parallelism is expressed through constraints, not annotations: +//! - Functions that don't take allocator parameters can't touch qubits +//! - Operations on disjoint allocators are independent +//! - Scopes define synchronization boundaries +//! +//! ## Available Passes +//! +//! - **Allocator Scope Analysis**: Track allocator lifetimes and accessibility +//! - **Operation Tagging**: Tag each operation with resources it touches +//! - **Dependency Graph**: Build edges between dependent operations +//! - **Parallel Layer Extraction**: Find maximal independent operation sets +//! +//! ## Usage +//! +//! ```rust +//! use zlup::analysis::{AllocatorAnalysis, OperationTagger, DependencyGraph}; +//! +//! let source = r#" +//! fn main() -> unit { +//! mut q := qalloc(2); +//! h q[0]; +//! cx (q[0], q[1]); +//! return; +//! } +//! "#; +//! +//! let program = zlup::parse(source).unwrap(); +//! let allocators = AllocatorAnalysis::analyze(&program); +//! let tagger = OperationTagger::tag(&program); +//! let graph = DependencyGraph::build(tagger.operations); +//! let layers = graph.parallel_layers(); +//! +//! assert!(allocators.allocators.contains_key("q")); +//! assert!(!layers.is_empty()); +//! ``` + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::ast::{Block, Expr, FnDecl, GateOp, MeasureOp, Program, Stmt, TopLevelDecl}; + +// ============================================================================= +// Resource Identifiers +// ============================================================================= + +/// A resource that an operation can touch. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Resource { + /// A qubit allocator (e.g., "q", "ancilla") - coarse-grained + Allocator(String), + /// A specific qubit within an allocator (e.g., "q[0]", "q[1]") - fine-grained + /// Only used when the index is a compile-time constant. + Qubit(String, i128), + /// A classical variable (e.g., "syndrome", "corrections") + Variable(String), +} + +impl Resource { + pub fn allocator(name: impl Into) -> Self { + Resource::Allocator(name.into()) + } + + pub fn qubit(allocator: impl Into, index: i128) -> Self { + Resource::Qubit(allocator.into(), index) + } + + pub fn variable(name: impl Into) -> Self { + Resource::Variable(name.into()) + } + + pub fn is_allocator(&self) -> bool { + matches!(self, Resource::Allocator(_)) + } + + pub fn is_qubit(&self) -> bool { + matches!(self, Resource::Qubit(_, _)) + } + + pub fn is_variable(&self) -> bool { + matches!(self, Resource::Variable(_)) + } + + /// Check if this resource touches qubits (either allocator or specific qubit) + pub fn touches_qubits(&self) -> bool { + matches!(self, Resource::Allocator(_) | Resource::Qubit(_, _)) + } +} + +// ============================================================================= +// Allocator Information +// ============================================================================= + +/// Information about a qubit allocator. +#[derive(Debug, Clone)] +pub struct AllocatorInfo { + /// The allocator name + pub name: String, + /// Size if statically known + pub size: Option, + /// Scope depth where defined + pub scope_depth: usize, + /// Line number where defined + pub defined_at_line: u32, +} + +// ============================================================================= +// Pass 1: Allocator Scope Analysis +// ============================================================================= + +/// Tracks allocator lifetimes and scope accessibility. +/// +/// This pass builds a map of what allocators are accessible at each point +/// in the program. Since Zlup requires static allocation, all allocators +/// are known at compile time. +#[derive(Debug, Default)] +pub struct AllocatorAnalysis { + /// All allocators in the program, by name + pub allocators: BTreeMap, + /// Allocators accessible in each function + pub function_allocators: BTreeMap>, +} + +impl AllocatorAnalysis { + /// Analyze a program for allocator information. + pub fn analyze(program: &Program) -> Self { + let mut analysis = Self::default(); + + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl { + analysis.analyze_function(fn_decl); + } + } + + analysis + } + + fn analyze_function(&mut self, fn_decl: &FnDecl) { + let mut local_allocators = BTreeSet::new(); + + // Analyze parameters for allocator types + for param in &fn_decl.params { + if Self::is_allocator_type_expr(¶m.ty) { + local_allocators.insert(param.name.clone()); + self.allocators.insert( + param.name.clone(), + AllocatorInfo { + name: param.name.clone(), + size: Self::extract_allocator_size_from_type(¶m.ty), + scope_depth: 0, + defined_at_line: fn_decl.location.as_ref().map(|l| l.line).unwrap_or(0), + }, + ); + } + } + + // Analyze body for qalloc statements + self.analyze_block(&fn_decl.body, &mut local_allocators, 1); + + self.function_allocators + .insert(fn_decl.name.clone(), local_allocators); + } + + fn analyze_block( + &mut self, + block: &Block, + local_allocators: &mut BTreeSet, + depth: usize, + ) { + for stmt in &block.statements { + self.analyze_stmt(stmt, local_allocators, depth); + } + } + + fn analyze_stmt(&mut self, stmt: &Stmt, local_allocators: &mut BTreeSet, depth: usize) { + match stmt { + Stmt::Binding(binding) => { + // Check if this is a qalloc + if let Some(value) = &binding.value + && Self::is_qalloc_expr(value) + { + local_allocators.insert(binding.name.clone()); + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + size: Self::extract_qalloc_size(value), + scope_depth: depth, + defined_at_line: binding.location.as_ref().map(|l| l.line).unwrap_or(0), + }, + ); + } + } + Stmt::Block(inner_block) => { + self.analyze_block(inner_block, local_allocators, depth + 1); + } + Stmt::If(if_stmt) => { + self.analyze_block(&if_stmt.then_body, local_allocators, depth + 1); + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + crate::ast::ElseBranch::Else(block) => { + self.analyze_block(block, local_allocators, depth + 1); + } + crate::ast::ElseBranch::ElseIf(nested_if) => { + self.analyze_stmt( + &Stmt::If(*nested_if.clone()), + local_allocators, + depth, + ); + } + } + } + } + Stmt::For(for_stmt) => { + self.analyze_block(&for_stmt.body, local_allocators, depth + 1); + } + _ => {} + } + } + + /// Check if a type represents a qubit allocator. + pub fn is_allocator_type_expr(ty: &crate::ast::TypeExpr) -> bool { + // Check for [n]qubit, qubit, or QAlloc types + match ty { + crate::ast::TypeExpr::Array(array_type) => { + // [n]qubit + matches!(array_type.element, crate::ast::TypeExpr::Qubit) + } + crate::ast::TypeExpr::Qubit => true, + crate::ast::TypeExpr::QAlloc(_) => true, + crate::ast::TypeExpr::Named(name) => { + // Named type that might be "qubit" + name.segments.first().is_some_and(|s| s == "qubit") + } + _ => false, + } + } + + /// Extract size from an allocator type if statically known. + fn extract_allocator_size_from_type(ty: &crate::ast::TypeExpr) -> Option { + if let crate::ast::TypeExpr::Array(array_type) = ty + && let Some(size_expr) = &array_type.size + && let Expr::IntLit(lit) = size_expr + { + return Some(lit.value as usize); + } + None + } + + /// Check if an expression is a qalloc call. + fn is_qalloc_expr(expr: &Expr) -> bool { + if let Expr::Call(call) = expr + && let Expr::Ident(ident) = &call.callee + { + return ident.name == "qalloc"; + } + false + } + + /// Extract size from a qalloc call if statically known. + fn extract_qalloc_size(expr: &Expr) -> Option { + if let Expr::Call(call) = expr + && let Some(first_arg) = call.args.first() + && let Expr::IntLit(lit) = first_arg + { + return Some(lit.value as usize); + } + None + } +} + +// ============================================================================= +// Pass 2: Operation Tagging +// ============================================================================= + +/// An operation in the program with its resource usage. +#[derive(Debug, Clone)] +pub struct TaggedOp { + /// Unique ID for this operation + pub id: usize, + /// Resources this operation reads from + pub reads: BTreeSet, + /// Resources this operation writes to + pub writes: BTreeSet, + /// Source location line number + pub line: u32, + /// Human-readable description + pub description: String, +} + +impl TaggedOp { + /// Check if this operation touches any qubit allocators. + pub fn touches_qubits(&self) -> bool { + self.reads.iter().any(|r| r.touches_qubits()) + || self.writes.iter().any(|r| r.touches_qubits()) + } + + /// Check if this operation is purely classical. + pub fn is_classical(&self) -> bool { + !self.touches_qubits() + } + + /// Get all resources this operation touches (reads or writes). + pub fn all_resources(&self) -> BTreeSet { + let mut all = self.reads.clone(); + all.extend(self.writes.clone()); + all + } +} + +/// Tags operations with the resources they touch. +#[derive(Debug, Default)] +pub struct OperationTagger { + /// All tagged operations + pub operations: Vec, + /// Next operation ID + next_id: usize, +} + +impl OperationTagger { + /// Tag all operations in a program. + pub fn tag(program: &Program) -> Self { + let mut tagger = Self::default(); + + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl { + tagger.tag_function(fn_decl); + } + } + + tagger + } + + fn tag_function(&mut self, fn_decl: &FnDecl) { + self.tag_block(&fn_decl.body); + } + + fn tag_block(&mut self, block: &Block) { + for stmt in &block.statements { + self.tag_stmt(stmt); + } + } + + fn tag_stmt(&mut self, stmt: &Stmt) { + match stmt { + Stmt::Gate(gate_op) => { + self.tag_gate_op(gate_op); + } + Stmt::Measure(measure_op) => { + self.tag_measure_op(measure_op); + } + Stmt::Expr(expr_stmt) => { + // Check if this is a gate or measure expression + match &expr_stmt.expr { + Expr::Gate(gate_expr) => { + self.tag_gate_expr(gate_expr); + } + Expr::Measure(measure_expr) => { + self.tag_measure_expr(measure_expr); + } + _ => { + // Other expression statements + let mut reads = BTreeSet::new(); + self.collect_expr_resources(&expr_stmt.expr, &mut reads); + + if !reads.is_empty() { + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes: BTreeSet::new(), + line: expr_stmt.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "expr".to_string(), + }); + } + } + } + } + Stmt::Binding(binding) => { + // Track variable definitions + let mut writes = BTreeSet::new(); + writes.insert(Resource::variable(&binding.name)); + + let mut reads = BTreeSet::new(); + if let Some(value) = &binding.value { + self.collect_expr_resources(value, &mut reads); + } + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes, + line: binding.location.as_ref().map(|l| l.line).unwrap_or(0), + description: format!("bind {}", binding.name), + }); + } + Stmt::Assign(assign) => { + let mut writes = BTreeSet::new(); + let mut reads = BTreeSet::new(); + + // Target is written + self.collect_expr_resources(&assign.target, &mut writes); + + // Value is read + self.collect_expr_resources(&assign.value, &mut reads); + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes, + line: assign.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "assign".to_string(), + }); + } + Stmt::Block(block) => { + self.tag_block(block); + } + Stmt::If(if_stmt) => { + // Condition is read + let mut reads = BTreeSet::new(); + self.collect_expr_resources(&if_stmt.condition, &mut reads); + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes: BTreeSet::new(), + line: if_stmt.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "if condition".to_string(), + }); + + self.tag_block(&if_stmt.then_body); + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + crate::ast::ElseBranch::Else(block) => self.tag_block(block), + crate::ast::ElseBranch::ElseIf(nested_if) => { + self.tag_stmt(&Stmt::If(*nested_if.clone())) + } + } + } + } + Stmt::For(for_stmt) => { + self.tag_block(&for_stmt.body); + } + Stmt::Return(ret) => { + let mut reads = BTreeSet::new(); + if let Some(value) = &ret.value { + self.collect_expr_resources(value, &mut reads); + } + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes: BTreeSet::new(), + line: ret.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "return".to_string(), + }); + } + _ => {} + } + } + + fn tag_gate_op(&mut self, gate: &GateOp) { + let mut writes = BTreeSet::new(); + + // Gates write to their target qubits + for target in &gate.targets { + self.collect_slot_ref_allocator(target, &mut writes); + } + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads: BTreeSet::new(), + writes, + line: gate.location.as_ref().map(|l| l.line).unwrap_or(0), + description: format!("{:?}", gate.kind), + }); + } + + fn tag_gate_expr(&mut self, gate: &crate::ast::GateExpr) { + let mut writes = BTreeSet::new(); + + // Collect allocators from the target expression + self.collect_allocators_from_expr(&gate.target, &mut writes); + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads: BTreeSet::new(), + writes, + line: gate.location.as_ref().map(|l| l.line).unwrap_or(0), + description: format!("{:?}", gate.kind), + }); + } + + fn tag_measure_op(&mut self, measure: &MeasureOp) { + let mut reads = BTreeSet::new(); + let writes = BTreeSet::new(); + + // Measure reads from qubits + for target in &measure.targets { + self.collect_slot_ref_allocator(target, &mut reads); + } + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes, + line: measure.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "measure".to_string(), + }); + } + + fn tag_measure_expr(&mut self, measure: &crate::ast::MeasureExpr) { + let mut reads = BTreeSet::new(); + let writes = BTreeSet::new(); + + // Collect allocators from the targets expression + self.collect_allocators_from_expr(&measure.targets, &mut reads); + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes, + line: measure.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "measure".to_string(), + }); + } + + /// Collect allocator resources from an expression that represents qubit targets. + fn collect_allocators_from_expr(&self, expr: &Expr, resources: &mut BTreeSet) { + match expr { + Expr::Index(index) => { + // e.g., q[0] - extract the base allocator name + if let Expr::Ident(ident) = &index.object { + resources.insert(Resource::allocator(&ident.name)); + } else { + self.collect_allocators_from_expr(&index.object, resources); + } + } + Expr::Ident(ident) => { + // Bare identifier might be an allocator + resources.insert(Resource::allocator(&ident.name)); + } + Expr::Tuple(tuple) => { + // e.g., (q[0], q[1]) for two-qubit gates + for elem in &tuple.elements { + self.collect_allocators_from_expr(elem, resources); + } + } + Expr::BracketArray(arr) => { + // e.g., [q[0], q[1], q[2]] + for elem in &arr.elements { + self.collect_allocators_from_expr(elem, resources); + } + } + Expr::SlotRef(slot) => { + resources.insert(Resource::allocator(&slot.allocator)); + } + _ => {} + } + } + + fn collect_slot_ref_allocator( + &self, + slot_ref: &crate::ast::SlotRef, + resources: &mut BTreeSet, + ) { + resources.insert(Resource::allocator(&slot_ref.allocator)); + } + + fn collect_expr_resources(&self, expr: &Expr, resources: &mut BTreeSet) { + match expr { + Expr::Ident(ident) => { + resources.insert(Resource::variable(&ident.name)); + } + Expr::Index(index) => { + // The object being indexed + self.collect_expr_resources(&index.object, resources); + } + Expr::Binary(binary) => { + self.collect_expr_resources(&binary.left, resources); + self.collect_expr_resources(&binary.right, resources); + } + Expr::Unary(unary) => { + self.collect_expr_resources(&unary.operand, resources); + } + Expr::Call(call) => { + self.collect_expr_resources(&call.callee, resources); + for arg in &call.args { + self.collect_expr_resources(arg, resources); + } + } + Expr::Field(field) => { + self.collect_expr_resources(&field.object, resources); + } + Expr::Measure(measure) => { + // Measure reads from qubits - collect allocators + self.collect_allocators_from_expr(&measure.targets, resources); + } + Expr::Gate(gate) => { + // Gate touches qubits - collect allocators + self.collect_allocators_from_expr(&gate.target, resources); + } + _ => {} + } + } + + fn next_id(&mut self) -> usize { + let id = self.next_id; + self.next_id += 1; + id + } +} + +// ============================================================================= +// Pass 3: Dependency Graph +// ============================================================================= + +/// The kind of dependency between operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DepKind { + /// Both operations touch the same qubit allocator + QubitDep(String), + /// One operation reads a variable written by another + DataDep(String), + /// Control flow dependency + ControlDep, +} + +/// An edge in the dependency graph. +#[derive(Debug, Clone)] +pub struct DepEdge { + /// Source operation ID + pub from: usize, + /// Target operation ID + pub to: usize, + /// Kind of dependency + pub kind: DepKind, +} + +/// Dependency graph for operations. +#[derive(Debug, Default)] +pub struct DependencyGraph { + /// All operations + pub operations: Vec, + /// Dependency edges (from -> to means "from must complete before to") + pub edges: Vec, +} + +impl DependencyGraph { + /// Build a dependency graph from tagged operations. + pub fn build(operations: Vec) -> Self { + let mut graph = Self { + operations, + edges: Vec::new(), + }; + + graph.compute_dependencies(); + graph + } + + fn compute_dependencies(&mut self) { + // For each pair of operations, check for dependencies + for i in 0..self.operations.len() { + for j in (i + 1)..self.operations.len() { + if let Some(kind) = self.check_dependency(i, j) { + self.edges.push(DepEdge { + from: i, + to: j, + kind, + }); + } + } + } + } + + fn check_dependency(&self, earlier: usize, later: usize) -> Option { + let op1 = &self.operations[earlier]; + let op2 = &self.operations[later]; + + // Check for qubit dependencies (WAW, RAW, WAR on allocators) + for res in &op1.writes { + if let Resource::Allocator(name) = res + && (op2.reads.contains(res) || op2.writes.contains(res)) + { + return Some(DepKind::QubitDep(name.clone())); + } + } + for res in &op1.reads { + if let Resource::Allocator(name) = res + && op2.writes.contains(res) + { + return Some(DepKind::QubitDep(name.clone())); + } + } + + // Check for data dependencies (classical variables) + for res in &op1.writes { + if let Resource::Variable(name) = res + && op2.reads.contains(res) + { + return Some(DepKind::DataDep(name.clone())); + } + } + + None + } + + /// Extract parallel layers - operations at the same layer can execute in parallel. + pub fn parallel_layers(&self) -> Vec> { + if self.operations.is_empty() { + return vec![]; + } + + // Compute the "level" of each operation (longest path from any root) + let mut levels = vec![0usize; self.operations.len()]; + let mut predecessors: Vec> = vec![vec![]; self.operations.len()]; + + // Build predecessor list + for edge in &self.edges { + predecessors[edge.to].push(edge.from); + } + + // Compute levels (topological order with level assignment) + let mut changed = true; + while changed { + changed = false; + for i in 0..self.operations.len() { + let max_pred_level = predecessors[i] + .iter() + .map(|&p| levels[p]) + .max() + .unwrap_or(0); + let new_level = if predecessors[i].is_empty() { + 0 + } else { + max_pred_level + 1 + }; + if new_level > levels[i] { + levels[i] = new_level; + changed = true; + } + } + } + + // Group operations by level + let max_level = levels.iter().copied().max().unwrap_or(0); + let mut layers: Vec> = vec![vec![]; max_level + 1]; + for (op_id, &level) in levels.iter().enumerate() { + layers[level].push(op_id); + } + + layers + } + + /// Get operations that can parallelize with a given operation. + pub fn independent_ops(&self, op_id: usize) -> Vec { + let mut dependent: BTreeSet = BTreeSet::new(); + + // Find all operations connected by edges + for edge in &self.edges { + if edge.from == op_id { + dependent.insert(edge.to); + } + if edge.to == op_id { + dependent.insert(edge.from); + } + } + + // Return operations not in dependent set + (0..self.operations.len()) + .filter(|&id| id != op_id && !dependent.contains(&id)) + .collect() + } + + /// Print the dependency graph for debugging. + pub fn debug_print(&self) { + println!("Operations:"); + for op in &self.operations { + let reads: Vec<_> = op.reads.iter().map(|r| format!("{:?}", r)).collect(); + let writes: Vec<_> = op.writes.iter().map(|r| format!("{:?}", r)).collect(); + println!( + " [{}] {} (line {}) reads: {:?}, writes: {:?}", + op.id, op.description, op.line, reads, writes + ); + } + + println!("\nDependencies:"); + for edge in &self.edges { + println!(" {} -> {} ({:?})", edge.from, edge.to, edge.kind); + } + + println!("\nParallel Layers:"); + for (level, layer) in self.parallel_layers().iter().enumerate() { + let descs: Vec<_> = layer + .iter() + .map(|&id| format!("[{}]{}", id, self.operations[id].description)) + .collect(); + println!(" Layer {}: {:?}", level, descs); + } + } +} + +// ============================================================================= +// Analysis Summary +// ============================================================================= + +/// Summary of parallelism analysis for a function. +#[derive(Debug)] +pub struct ParallelismSummary { + /// Function name + pub function_name: String, + /// Total operations + pub total_ops: usize, + /// Number of parallel layers + pub num_layers: usize, + /// Maximum parallelism (largest layer) + pub max_parallelism: usize, + /// Number of purely classical operations + pub classical_ops: usize, + /// Number of quantum operations + pub quantum_ops: usize, +} + +/// Analyze parallelism in a program. +pub fn analyze_parallelism(program: &Program) -> Vec { + let mut summaries = vec![]; + + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl { + let tagger = OperationTagger::tag(&Program { + name: program.name.clone(), + declarations: vec![TopLevelDecl::Fn(fn_decl.clone())], + location: None, + }); + + let graph = DependencyGraph::build(tagger.operations); + let layers = graph.parallel_layers(); + + let classical_ops = graph + .operations + .iter() + .filter(|op| op.is_classical()) + .count(); + let quantum_ops = graph + .operations + .iter() + .filter(|op| op.touches_qubits()) + .count(); + + summaries.push(ParallelismSummary { + function_name: fn_decl.name.clone(), + total_ops: graph.operations.len(), + num_layers: layers.len(), + max_parallelism: layers.iter().map(|l| l.len()).max().unwrap_or(0), + classical_ops, + quantum_ops, + }); + } + } + + summaries +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_and_analyze(source: &str) -> DependencyGraph { + let program = crate::parse(source).expect("parse failed"); + let tagger = OperationTagger::tag(&program); + DependencyGraph::build(tagger.operations) + } + + #[test] + fn test_gates_same_allocator() { + // Gates on the same allocator are treated as dependent (conservative). + // This is allocator-level tracking, not qubit-index-level. + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + h q[0]; + h q[1]; + h q[2]; + h q[3]; + return; + } + "#; + + let graph = parse_and_analyze(source); + + // We should have: qalloc binding, 4 H gates, return + assert_eq!(graph.operations.len(), 6); + + // All H gates touch the same allocator "q", so they're serialized + let h_ops: Vec<_> = graph + .operations + .iter() + .filter(|op| op.description.contains("H")) + .collect(); + assert_eq!(h_ops.len(), 4); + + // All should write to allocator "q" + for op in &h_ops { + assert!( + op.writes.contains(&Resource::allocator("q")), + "H gate should touch allocator q" + ); + } + } + + #[test] + fn test_dependent_gates() { + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return; + } + "#; + + let graph = parse_and_analyze(source); + + // We should have: qalloc binding, H gate, CX gate, return + assert_eq!(graph.operations.len(), 4, "Expected 4 operations"); + + // H and CX both touch allocator "q", so they're dependent + let h_op = graph + .operations + .iter() + .find(|op| op.description.contains("H")); + let cx_op = graph + .operations + .iter() + .find(|op| op.description.contains("CX")); + + assert!(h_op.is_some(), "Should have H gate"); + assert!(cx_op.is_some(), "Should have CX gate"); + + // Both should touch allocator "q" + assert!( + h_op.unwrap().writes.contains(&Resource::allocator("q")), + "H should touch q" + ); + assert!( + cx_op.unwrap().writes.contains(&Resource::allocator("q")), + "CX should touch q" + ); + } + + #[test] + fn test_disjoint_allocators() { + let source = r#" + fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + h q1[0]; + h q2[0]; + cx (q1[0], q1[1]); + cx (q2[0], q2[1]); + return; + } + "#; + + let graph = parse_and_analyze(source); + + // Operations on q1 and q2 should be independent + // Find the H gates + let h_ops: Vec<_> = graph + .operations + .iter() + .enumerate() + .filter(|(_, op)| op.description.contains("H")) + .map(|(id, _)| id) + .collect(); + + assert_eq!(h_ops.len(), 2); + + // Check that the two H gates are independent of each other + let independent_of_first = graph.independent_ops(h_ops[0]); + assert!( + independent_of_first.contains(&h_ops[1]), + "H gates on different allocators should be independent" + ); + } + + #[test] + fn test_classical_quantum_independence() { + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + x := 1 + 2; + h q[0]; + y := x * 3; + cx (q[0], q[1]); + return; + } + "#; + + let graph = parse_and_analyze(source); + + // Classical operations (x, y bindings) should be independent of quantum ops + let classical: Vec<_> = graph + .operations + .iter() + .filter(|op| op.is_classical() && op.description.starts_with("bind")) + .collect(); + + let quantum: Vec<_> = graph + .operations + .iter() + .filter(|op| op.touches_qubits()) + .collect(); + + assert!(!classical.is_empty(), "Should have classical ops"); + assert!(!quantum.is_empty(), "Should have quantum ops"); + } + + #[test] + fn test_allocator_analysis() { + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + mut ancilla := qalloc(2); + h q[0]; + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + assert!(analysis.allocators.contains_key("q")); + assert!(analysis.allocators.contains_key("ancilla")); + assert_eq!(analysis.allocators["q"].size, Some(4)); + assert_eq!(analysis.allocators["ancilla"].size, Some(2)); + } + + #[test] + fn test_measurement_creates_dependency() { + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + h q[0]; + m := mz([2]u1) q; + return; + } + "#; + + let graph = parse_and_analyze(source); + + // The measure is part of a binding (m := mz...), so it's recorded as "bind m" + // but should still track that it reads from allocator q + let m_binding = graph + .operations + .iter() + .find(|op| op.description == "bind m"); + assert!(m_binding.is_some(), "Should have binding for m"); + + // The binding should read from allocator "q" (via the measure expression) + // Note: Currently we track mz as reading q through collect_expr_resources + // but we need to enhance the binding handler to detect measure expressions + } + + #[test] + fn test_parallel_layers_correct() { + let source = r#" + fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + h q1[0]; + h q2[0]; + return; + } + "#; + + let graph = parse_and_analyze(source); + let layers = graph.parallel_layers(); + + // Layer 0: Both qalloc bindings (parallel - different variables) + // Layer 1: Both H gates (parallel - different allocators) + // Layer 2: return + + // The H gates should be in the same layer since they're independent + let h_ops: Vec = graph + .operations + .iter() + .enumerate() + .filter(|(_, op)| op.description.contains("H")) + .map(|(id, _)| id) + .collect(); + + assert_eq!(h_ops.len(), 2); + + // Find which layer contains the H gates + let h_layer = layers.iter().find(|layer| layer.contains(&h_ops[0])); + assert!(h_layer.is_some()); + assert!( + h_layer.unwrap().contains(&h_ops[1]), + "Both H gates should be in the same layer (parallel)" + ); + } + + #[test] + fn test_data_dependency_classical() { + let source = r#" + fn main() -> unit { + x := 1; + y := x + 1; + z := y + 1; + return; + } + "#; + + let graph = parse_and_analyze(source); + + // x, y, z form a chain of data dependencies + // y depends on x, z depends on y + let layers = graph.parallel_layers(); + + // Each binding should be in a different layer due to data dependencies + // (x, return could be parallel with others if no dep, but y needs x, z needs y) + assert!( + layers.len() >= 3, + "Should have at least 3 layers for x->y->z chain" + ); + } + + #[test] + fn test_function_parameter_allocator() { + let source = r#" + fn apply_h(q: [4]qubit) -> unit { + h q[0]; + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + // Parameter q should be detected as an allocator + assert!( + analysis.allocators.contains_key("q"), + "Parameter q should be detected as allocator" + ); + } + + #[test] + fn test_debug_print() { + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return; + } + "#; + + let graph = parse_and_analyze(source); + + // Just verify debug_print doesn't panic + graph.debug_print(); + } + + #[test] + fn test_parallelism_summary() { + let source = r#" + fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + h q1[0]; + h q2[0]; + x := 1 + 2; + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let summaries = super::analyze_parallelism(&program); + + assert_eq!(summaries.len(), 1); + let summary = &summaries[0]; + assert_eq!(summary.function_name, "main"); + assert!(summary.total_ops > 0); + assert!( + summary.quantum_ops >= 2, + "Should have at least 2 quantum ops (H gates)" + ); + assert!( + summary.classical_ops >= 1, + "Should have at least 1 classical op (x binding)" + ); + } + + #[test] + fn test_empty_function() { + let source = r#" + fn empty() -> unit { + return; + } + "#; + + let graph = parse_and_analyze(source); + + // Should have just the return operation + assert!( + graph.operations.len() <= 1, + "Empty function should have minimal ops" + ); + let layers = graph.parallel_layers(); + assert!( + layers.len() <= 1, + "Empty function should have at most 1 layer" + ); + } + + #[test] + fn test_purely_classical_function() { + let source = r#" + fn classical() -> i32 { + a := 1; + b := 2; + c := a + b; + return c; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let summaries = super::analyze_parallelism(&program); + + assert_eq!(summaries.len(), 1); + let summary = &summaries[0]; + assert_eq!(summary.quantum_ops, 0, "Should have no quantum ops"); + assert!(summary.classical_ops > 0, "Should have classical ops"); + } + + #[test] + fn test_nested_scopes() { + let source = r#" + fn nested() -> unit { + mut q := qalloc(2); + { + h q[0]; + { + cx (q[0], q[1]); + } + } + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + // Allocator q should still be tracked despite nested scopes + assert!(analysis.allocators.contains_key("q")); + assert_eq!(analysis.allocators["q"].size, Some(2)); + } + + #[test] + fn test_multiple_functions() { + let source = r#" + fn func1() -> unit { + mut q := qalloc(2); + h q[0]; + return; + } + + fn func2() -> unit { + mut r := qalloc(3); + h r[0]; + h r[1]; + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let summaries = super::analyze_parallelism(&program); + + assert_eq!(summaries.len(), 2, "Should analyze both functions"); + + let func1 = summaries.iter().find(|s| s.function_name == "func1"); + let func2 = summaries.iter().find(|s| s.function_name == "func2"); + + assert!(func1.is_some(), "Should have func1 summary"); + assert!(func2.is_some(), "Should have func2 summary"); + + // func2 has more H gates + assert!(func2.unwrap().quantum_ops >= func1.unwrap().quantum_ops); + } + + #[test] + fn test_if_statement_analysis() { + let source = r#" + fn conditional(cond: bool) -> unit { + mut q := qalloc(2); + if cond { + h q[0]; + } else { + x q[0]; + } + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + // Allocator should be tracked even inside conditionals + assert!(analysis.allocators.contains_key("q")); + } + + #[test] + fn test_for_loop_analysis() { + let source = r#" + fn looped() -> unit { + mut q := qalloc(4); + for i in 0..4 { + h q[i]; + } + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + assert!(analysis.allocators.contains_key("q")); + assert_eq!(analysis.allocators["q"].size, Some(4)); + } +} diff --git a/exp/zlup/src/ast.rs b/exp/zlup/src/ast.rs new file mode 100644 index 000000000..323b361ca --- /dev/null +++ b/exp/zlup/src/ast.rs @@ -0,0 +1,1554 @@ +//! AST node definitions for Zluppy programs. +//! +//! This module defines the Abstract Syntax Tree (AST) nodes for representing +//! Zluppy quantum programs. The design mirrors the Python SLR-AST for easy +//! conversion while supporting Zig-inspired language features. +//! +//! Design principles: +//! - Immutable data structures +//! - Explicit source location tracking +//! - Direct mapping to SLR-AST where applicable +//! - Support for comptime evaluation + +use serde::{Deserialize, Serialize}; +use std::fmt; + +// ============================================================================= +// Source Location +// ============================================================================= + +/// Source location for error reporting. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, + pub end_line: u32, + pub end_column: u32, + pub file: Option, +} + +impl SourceLocation { + pub fn new(line: u32, column: u32) -> Self { + Self { + line, + column, + end_line: line, + end_column: column + 1, + file: None, + } + } + + pub fn with_end(line: u32, column: u32, end_line: u32, end_column: u32) -> Self { + Self { + line, + column, + end_line, + end_column, + file: None, + } + } + + pub fn with_file(line: u32, column: u32, file: impl Into) -> Self { + Self { + line, + column, + end_line: line, + end_column: column + 1, + file: Some(file.into()), + } + } +} + +impl fmt::Display for SourceLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.file { + Some(file) => write!(f, "{}:{}:{}", file, self.line, self.column), + None => write!(f, "{}:{}", self.line, self.column), + } + } +} + +// ============================================================================= +// Attributes (Metadata) +// ============================================================================= + +/// Attribute value types. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AttributeValue { + /// Boolean flag: @attr(noisy, true) + Bool(bool), + /// Integer value: @attr(round, 0) + Int(i64), + /// Float value: @attr(error_rate, 0.001) + Float(f64), + /// String value: @attr(kind, "syndrome") + String(String), + /// Identifier value: @attr(gate_type, Hadamard) + Ident(String), +} + +/// An attribute attached to a statement or construct. +/// Examples: @attr(round, 0), @attr(kind, "syndrome"), @attrs({round: 0, kind: "syndrome"}) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Attribute { + /// Attribute name (e.g., "round", "type", "noisy") + pub name: String, + /// Attribute value (None for boolean flags like @noisy) + pub value: Option, + pub location: Option, +} + +impl Attribute { + /// Create a boolean flag attribute (e.g., @noisy) + pub fn flag(name: impl Into) -> Self { + Self { + name: name.into(), + value: None, + location: None, + } + } + + /// Create an attribute with a value + pub fn with_value(name: impl Into, value: AttributeValue) -> Self { + Self { + name: name.into(), + value: Some(value), + location: None, + } + } +} + +// ============================================================================= +// Program +// ============================================================================= + +/// Root node representing a Zluppy program. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Program { + pub name: String, + pub declarations: Vec, + pub location: Option, +} + +/// Top-level declarations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TopLevelDecl { + Binding(Binding), + Fn(FnDecl), + ExternFn(ExternFnDecl), + Struct(StructDecl), + Enum(EnumDecl), + Union(UnionDecl), + ErrorSet(ErrorSetDecl), + FaultSet(FaultSetDecl), + Test(TestDecl), + DeclareGate(TargetGateDecl), + Gate(CompositeGateDecl), +} + +// ============================================================================= +// Declarations +// ============================================================================= + +/// Binding declaration (unified const/var with Pascal/Go syntax) +/// Immutable: `x := value;` or `x: T = value;` +/// Mutable: `mut x := value;` or `mut x: T = value;` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Binding { + pub name: String, + pub ty: Option, + pub value: Option, // None means undefined + pub is_mutable: bool, // true if `mut` keyword present + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Alias binding - creates a named view into existing data. +/// `alias name := slice_expr;` +/// +/// Aliases are immutable views with overlap checking. +/// Unlike regular bindings, aliases track their source for overlap detection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasBinding { + /// Name of the alias + pub name: String, + /// The source expression (must be a slice/range expression) + pub source: Expr, + /// Source location + pub location: Option, +} + +/// Function declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FnDecl { + pub name: String, + pub params: Vec, + pub return_type: Option, + pub body: Block, + pub is_pub: bool, + pub is_inline: bool, + /// Error handling mode for the function body. + /// `try` = collect all errors (QEC pattern) + /// `try!` = stop on first error (traditional) + /// None = no automatic error handling + pub error_mode: Option, + pub doc_comment: Option, + pub location: Option, +} + +/// Function parameter. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Param { + pub name: String, + pub ty: TypeExpr, + pub is_comptime: bool, + pub location: Option, +} + +/// External function declaration (FFI). +/// `@link("libdecoder") extern "C" fn decode(data: [*]u8, len: usize) -> i32;` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternFnDecl { + pub name: String, + /// Library to link against (e.g., "libdecoder", "pecos_runtime") + pub library: Option, + /// Calling convention (e.g., "C", "Rust") + pub calling_convention: String, + pub params: Vec, + pub return_type: Option, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Struct declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructDecl { + pub name: String, + pub fields: Vec, + pub methods: Vec, + /// Associated constants defined within the struct + pub associated_consts: Vec, + pub is_pub: bool, + pub is_packed: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Struct field. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructField { + pub name: String, + pub ty: TypeExpr, + pub default: Option, + pub doc_comment: Option, + pub location: Option, +} + +/// Enum declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnumDecl { + pub name: String, + pub tag_type: Option, + pub variants: Vec, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Enum variant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnumVariant { + pub name: String, + pub value: Option, + pub location: Option, +} + +/// Union declaration (tagged union / sum type). +/// Example: `const Value = union(enum) { Int: i32, Float: f64, None }` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnionDecl { + pub name: String, + /// The tag type: None = untagged, Some(None) = auto-tagged (enum), Some(Some(ty)) = external tag + pub tag: Option>, + pub fields: Vec, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Union field (variant with optional payload type). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnionField { + pub name: String, + /// The payload type for this variant. None = no payload (like an enum variant) + pub ty: Option, + pub location: Option, +} + +/// Error set declaration - classical/logical errors that crash if unhandled. +/// `DecodeError := error { SyndromeAmbiguous, WeightTooHigh };` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorSetDecl { + pub name: String, + /// The error variants in this set + pub variants: Vec, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Fault set declaration - quantum/physical faults, collected in try blocks. +/// `QuantumFault := fault { Leakage, QubitLoss, GateFailure };` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaultSetDecl { + pub name: String, + /// The fault variants in this set + pub variants: Vec, // Reuse ErrorVariant structure + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// An error/fault variant within an error or fault set. +/// Can optionally have associated data: `Leakage: struct { gate: []const u8, qubit: usize }` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorVariant { + pub name: String, + /// Optional associated data type + pub data_type: Option, + pub location: Option, +} + +/// Test declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestDecl { + pub name: String, + pub body: Block, + pub location: Option, +} + +// ============================================================================= +// Custom Gate Declarations +// ============================================================================= + +/// A gate parameter (angle or classical parameter). +/// Example: `theta` in `declare gate rx(theta)(q);` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateParam { + pub name: String, + pub ty: Option, + pub location: Option, +} + +/// A qubit parameter for gate declarations. +/// Example: `q` in `declare gate h()(q);` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QubitParam { + pub name: String, + pub location: Option, +} + +/// Target gate declaration — declares a gate that will be provided by the backend. +/// Example: `declare gate rx(theta: a64)(q);` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TargetGateDecl { + pub name: String, + pub params: Vec, + pub qubits: Vec, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Composite gate declaration — a gate defined in terms of other gates. +/// Example: `gate bell()(q0, q1) { h q0; cx (q0, q1); }` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompositeGateDecl { + pub name: String, + pub params: Vec, + pub qubits: Vec, + pub body: Block, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +// ============================================================================= +// Allocator Declarations (Quantum-specific) +// ============================================================================= + +/// Qubit allocator declaration (maps to SLR AllocatorDecl). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AllocatorDecl { + pub name: String, + pub capacity: u32, + pub parent: Option, + pub location: Option, +} + +/// Classical register declaration (maps to SLR RegisterDecl). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterDecl { + pub name: String, + pub size: u32, + pub is_result: bool, + pub location: Option, +} + +// ============================================================================= +// Statements +// ============================================================================= + +/// Statement types. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Stmt { + Binding(Binding), + Alias(AliasBinding), + Assign(AssignStmt), + If(IfStmt), + For(ForStmt), + Switch(SwitchStmt), + Tick(TickStmt), + TryBlock(TryBlockStmt), + Return(ReturnStmt), + Break(BreakStmt), + Continue(ContinueStmt), + Defer(DeferStmt), + Errdefer(ErrDeferStmt), + Block(Block), + Expr(ExprStmt), + + // Quantum operations (map directly to SLR) + Gate(GateOp), + Prepare(PrepareOp), + Measure(MeasureOp), + Barrier(BarrierOp), +} + +/// Try block statement for error handling. +/// `try { }` - collect all errors (QEC pattern), returns []E!T +/// `try! { }` - stop on first error (traditional), returns E!T +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TryBlockStmt { + /// The error handling mode + pub mode: TryMode, + /// The block body + pub body: Block, + /// Optional catch clause: catch |err| { ... } + pub catch_clause: Option, + pub location: Option, +} + +/// Error handling mode for try blocks and functions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TryMode { + /// `try` - collect all errors, continue executing (QEC pattern) + Collect, + /// `try!` - stop on first error, propagate immediately (traditional) + Propagate, +} + +/// Catch clause for try blocks. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatchClause { + /// The error capture variable name + pub capture: String, + /// The catch body (block or expression) + pub body: Expr, + pub location: Option, +} + +/// Tick statement - a time slice of parallel quantum gates. +/// Maps to PECOS TickCircuit's tick concept. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TickStmt { + /// Optional label for the tick (for QEC rounds, debugging, etc.) + pub label: Option, + /// Attributes attached to this tick (e.g., @round(0), @type("syndrome")) + /// Using Vec to preserve order of declaration + pub attrs: Vec, + /// Statements within this tick (gates execute in parallel) + pub body: Vec, + pub location: Option, +} + +/// Assignment statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssignStmt { + pub target: Expr, // LValue + pub op: AssignOp, + pub value: Expr, + pub location: Option, +} + +/// Assignment operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AssignOp { + Assign, // = + AddAssign, // += + SubAssign, // -= + MulAssign, // *= + DivAssign, // /= + AndAssign, // &= + OrAssign, // |= + XorAssign, // ^= +} + +/// If statement. +/// Supports optional unwrapping: if (opt) |value| { ... } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IfStmt { + pub condition: Expr, + /// Optional capture variable for unwrapping optionals: if (opt) |value| { ... } + pub capture: Option, + pub then_body: Block, + pub else_body: Option, + pub location: Option, +} + +/// Else branch (can be else-if or else). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ElseBranch { + ElseIf(Box), + Else(Block), +} + +/// For statement (bounded iteration - NASA Power of 10 compliant). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForStmt { + pub label: Option, + pub is_inline: bool, + pub range: ForRange, + pub captures: Vec, + pub body: Block, + pub location: Option, +} + +/// For loop range. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ForRange { + /// `0..n` or `start..end` + Range { start: Expr, end: Expr }, + /// Iterate over a collection + Collection(Expr), +} + +/// Switch statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwitchStmt { + pub value: Expr, + pub prongs: Vec, + pub location: Option, +} + +/// Switch prong. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwitchProng { + pub cases: Vec, + pub is_else: bool, + pub body: Expr, + pub location: Option, +} + +/// Switch case. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwitchCase { + pub value: Expr, + pub end: Option, // For ranges + pub location: Option, +} + +/// Return statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReturnStmt { + pub value: Option, + pub location: Option, +} + +/// Break statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BreakStmt { + pub label: Option, + pub value: Option, + pub location: Option, +} + +/// Continue statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContinueStmt { + pub label: Option, + pub location: Option, +} + +/// Defer statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeferStmt { + pub body: Box, + pub location: Option, +} + +/// Errdefer statement - executes on error return. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrDeferStmt { + pub body: Box, + /// Optional capture name for the error value: errdefer |err| { ... } + pub capture: Option, + pub location: Option, +} + +/// Block of statements. +/// Can have an optional trailing expression that is the block's return value (like Rust). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + pub label: Option, + /// Attributes attached to this block (e.g., @attr(kind, "syndrome")) + pub attrs: Vec, + pub statements: Vec, + /// Optional trailing expression (block's return value) + pub trailing_expr: Option>, + pub location: Option, +} + +/// Expression statement. +/// Can have prefix attributes for annotating gate calls. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExprStmt { + pub expr: Expr, + /// Attributes attached to this statement (e.g., @syndrome("X") for gates) + pub attrs: Vec, + pub location: Option, +} + +// ============================================================================= +// Quantum Operations (map to SLR nodes) +// ============================================================================= + +/// Gate operation (maps to SLR GateOp). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateOp { + pub kind: GateKind, + pub targets: Vec, + pub params: Vec, + /// Attributes attached to this gate (e.g., @preserve, @round(0)) + pub attrs: Vec, + pub location: Option, +} + +/// Gate types (matches SLR GateKind). +/// Note: Gate names like SXX, RZZ use uppercase for clarity with quantum conventions. +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GateKind { + // Single-qubit Paulis + X, + Y, + Z, + + // Hadamard + H, + + // T gates (fourth root of Z) + T, + Tdg, + + // Square root gates (SZ is the S gate / sqrt(Z)) + SX, + SY, + SZ, + SXdg, + SYdg, + SZdg, + + // Rotation gates (parameterized) + RX, + RY, + RZ, + + // Two-qubit gates + CX, + CY, + CZ, + CH, + SWAP, + ISWAP, + + // Two-qubit rotation gates + SXX, + SYY, + SZZ, + SXXdg, + SYYdg, + SZZdg, + RZZ, + + // Three-qubit gates + CCX, // Toffoli gate + + // Face rotations + F, + Fdg, + F4, + F4dg, + + // Prepare/reset operations (pz = prepare Z, reset to |0⟩) + PZ, +} + +impl GateKind { + /// Number of qubit arguments required. + pub fn arity(&self) -> usize { + use GateKind::*; + match self { + CCX => 3, + CX | CY | CZ | CH | SWAP | ISWAP | SXX | SYY | SZZ | SXXdg | SYYdg | SZZdg | RZZ => 2, + _ => 1, + } + } + + /// Whether this gate takes angle parameters. + pub fn is_parameterized(&self) -> bool { + use GateKind::*; + matches!(self, RX | RY | RZ | RZZ) + } + + /// Whether this gate is a preparation/reset operation. + /// PZ resets qubits to |0⟩ and can be applied to unprepared qubits. + pub fn is_prepare(&self) -> bool { + matches!(self, GateKind::PZ) + } +} + +/// Prepare operation (maps to SLR PrepareOp). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrepareOp { + pub allocator: String, + pub slots: Option>, // None means prepare_all + pub location: Option, +} + +/// Measure operation (maps to SLR MeasureOp). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeasureOp { + pub targets: Vec, + pub results: Vec, + pub location: Option, +} + +/// Barrier operation (maps to SLR BarrierOp). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BarrierOp { + pub allocators: Vec, + pub location: Option, +} + +/// Reference to a qubit slot (maps to SLR SlotRef). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotRef { + pub allocator: String, + pub index: Box, // Can be comptime or runtime + pub location: Option, +} + +/// Reference to a classical bit (maps to SLR BitRef). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BitRef { + pub register: String, + pub index: Box, + pub location: Option, +} + +// ============================================================================= +// Expressions +// ============================================================================= + +/// Expression types. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Expr { + // Literals + IntLit(IntLit), + FloatLit(FloatLit), + AngleLit(Box), // 0.25 turns, pi/4 rad - angle with explicit unit + TypeAscription(Box), // 42 u32, 1/4 f64 - expression with type suffix + BoolLit(BoolLit), + StringLit(StringLit), + FString(Box), // f"Hello {name}!" - Python-style interpolation + CharLit(CharLit), + Null(NullLit), + Undefined(UndefinedLit), + Unit(UnitLit), + + // Identifiers and references + Ident(Ident), + SlotRef(Box), + BitRef(Box), + + // Operators + Binary(Box), + Unary(Box), + + // Access + Field(Box), + Index(Box), + Call(Box), + BatchApply(Box), // h { q[0], q[1] } - batch gate apply + + // Special + If(Box), + Block(Box), + Comptime(Box), + Builtin(Box), + AnonStruct(Box), // struct { x: i32, y: i32 } - anonymous struct type + StructInit(Box), + ArrayInit(Box), + BracketArray(Box), // [a, b, c] literal + Tuple(Box), // (a, b) tuple + Set(Box), // {a, b, c} set literal + Range(Box), + Measure(Box), // mz(T) targets - measurement + Gate(Box), // h q[0], rx(0.123) q[0] - quantum gate + + // Error/fault handling + ErrorValue(Box), // error.Name literal + FaultValue(Box), // fault.Name literal + Catch(Box), // a catch |err| b + TryBlock(Box), // try { } or try! { } as expression + + // Function literal (for comptime type constructors) + FnLit(Box), // fn(params) -> ret { body } + + // Result emission (program output channel - special, never elided) + Result(Box), // result("tag", value) - emit to caller + + // Side-channel communication (sticky/barrier semantics) + Channel(Box), // @emit.channel.command(...) - log, sim, hw, custom +} + +/// Try block as expression. +/// `errors := try { ... };` +/// `result := try! { ... } catch |err| { default };` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TryBlockExpr { + /// The error handling mode + pub mode: TryMode, + /// The block body + pub body: Block, + /// Optional catch clause + pub catch_clause: Option, + pub location: Option, +} + +impl Expr { + /// Get the source location of this expression, if available. + pub fn get_location(&self) -> Option { + match self { + Expr::IntLit(lit) => lit.location.clone(), + Expr::FloatLit(lit) => lit.location.clone(), + Expr::AngleLit(lit) => lit.location.clone(), + Expr::TypeAscription(asc) => asc.location.clone(), + Expr::BoolLit(lit) => lit.location.clone(), + Expr::StringLit(lit) => lit.location.clone(), + Expr::FString(fstr) => fstr.location.clone(), + Expr::CharLit(lit) => lit.location.clone(), + Expr::Null(lit) => lit.location.clone(), + Expr::Undefined(lit) => lit.location.clone(), + Expr::Unit(lit) => lit.location.clone(), + Expr::Ident(ident) => ident.location.clone(), + Expr::SlotRef(slot) => slot.location.clone(), + Expr::BitRef(bit) => bit.location.clone(), + Expr::Binary(binary) => binary.location.clone(), + Expr::Unary(unary) => unary.location.clone(), + Expr::Field(field) => field.location.clone(), + Expr::Index(index) => index.location.clone(), + Expr::Call(call) => call.location.clone(), + Expr::BatchApply(batch) => batch.location.clone(), + Expr::If(if_expr) => if_expr.location.clone(), + Expr::Block(block) => block.location.clone(), + Expr::Comptime(comptime) => comptime.location.clone(), + Expr::Builtin(builtin) => builtin.location.clone(), + Expr::AnonStruct(anon) => anon.location.clone(), + Expr::StructInit(init) => init.location.clone(), + Expr::ArrayInit(init) => init.location.clone(), + Expr::BracketArray(arr) => arr.location.clone(), + Expr::Tuple(tuple) => tuple.location.clone(), + Expr::Set(set) => set.location.clone(), + Expr::Range(range) => range.location.clone(), + Expr::Measure(measure) => measure.location.clone(), + Expr::Gate(gate) => gate.location.clone(), + Expr::ErrorValue(err) => err.location.clone(), + Expr::FaultValue(fault) => fault.location.clone(), + Expr::Catch(catch) => catch.location.clone(), + Expr::TryBlock(try_block) => try_block.location.clone(), + Expr::FnLit(func) => func.location.clone(), + Expr::Result(result) => result.location.clone(), + Expr::Channel(channel) => channel.location.clone(), + } + } +} + +/// Integer literal with optional type suffix. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntLit { + pub value: i128, + /// Type suffix (e.g., "u32", "i8", "usize") + pub suffix: Option, + pub location: Option, +} + +/// Float literal with optional type suffix. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FloatLit { + pub value: f64, + /// Type suffix (e.g., "f32", "f64") + pub suffix: Option, + pub location: Option, +} + +/// Angle literal with explicit unit: `0.25 turns` or `pi/4 rad` +/// Units: turns (native, 1 turn = full rotation), rad (radians) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AngleLit { + /// The numeric value expression (can be a literal or arithmetic like pi/4) + pub value: Expr, + /// The angle unit + pub unit: AngleUnit, + pub location: Option, +} + +/// Type ascription: expression with explicit type suffix +/// Examples: `42 u32`, `1/4 f64`, `(a + b) i64` +/// Allows type annotation on expressions with space for readability. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypeAscription { + /// The expression to type + pub value: Expr, + /// The type name as a string (e.g., "u32", "f64", "a64") + pub type_name: String, + pub location: Option, +} + +/// Angle unit specifier +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AngleUnit { + /// Turns - native unit, 1 turn = full rotation (360 degrees) + /// Common values: 0.25 = quarter turn, 0.5 = half turn, 0.125 = T gate + Turns, + /// Radians - mathematical convention + /// pi/2 = quarter turn, pi = half turn + Rad, +} + +impl AngleUnit { + /// Convert a value in this unit to turns (the native unit) + pub fn to_turns(&self, value: f64) -> f64 { + match self { + AngleUnit::Turns => value, + AngleUnit::Rad => value / (2.0 * std::f64::consts::PI), + } + } + + /// Convert a value in turns to this unit + pub fn from_turns(&self, turns: f64) -> f64 { + match self { + AngleUnit::Turns => turns, + AngleUnit::Rad => turns * 2.0 * std::f64::consts::PI, + } + } +} + +/// Boolean literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoolLit { + pub value: bool, + pub location: Option, +} + +/// String literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StringLit { + pub value: String, + pub location: Option, +} + +/// F-string (Python-style interpolated string): f"Hello {name}!" +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FStringExpr { + pub parts: Vec, + pub location: Option, +} + +/// A part of an f-string - either literal text or an interpolated expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum FStringPart { + /// Literal text portion + Text(String), + /// Interpolated expression with optional format spec: {expr} or {expr:.2f} + Expr { + expr: Expr, + /// Optional format specifier (e.g., ".2f", ">10", "08d") + format: Option, + }, +} + +/// Character literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CharLit { + pub value: char, + pub location: Option, +} + +/// Null literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NullLit { + pub location: Option, +} + +/// Undefined literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UndefinedLit { + pub location: Option, +} + +/// Unit literal - the single value of the unit type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnitLit { + pub location: Option, +} + +/// Identifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ident { + pub name: String, + pub location: Option, +} + +/// Binary expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BinaryExpr { + pub op: BinaryOp, + pub left: Expr, + pub right: Expr, + pub location: Option, +} + +/// Binary operators (matches SLR BinaryOp). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BinaryOp { + // Arithmetic + Add, + Sub, + Mul, + Div, + Mod, + + // Comparison + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + + // Membership (for sets) + In, + NotIn, + + // Logical + And, + Or, + + // Optional + Orelse, // a orelse b - returns a if not null, else b + + // Error handling + Catch, // a catch |err| b - unwrap error union or handle error + + // Bitwise + BitAnd, + BitOr, + BitXor, + Shl, + Shr, +} + +/// Unary expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnaryExpr { + pub op: UnaryOp, + pub operand: Expr, + pub location: Option, +} + +/// Unary operators (matches SLR UnaryOp). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum UnaryOp { + Neg, // - + Not, // ! + BitNot, // ~ + AddrOf, // & + Deref, // * + OptionalUnwrap, // .? + ErrorUnwrap, // .! + Try, // try - error propagation +} + +/// Field access expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldExpr { + pub object: Expr, + pub field: String, + pub location: Option, +} + +/// Index expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexExpr { + pub object: Expr, + pub index: Expr, + pub location: Option, +} + +/// Call expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallExpr { + pub callee: Expr, + pub args: Vec, + pub location: Option, +} + +/// Batch apply expression: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } +/// For gates where application order doesn't matter (set semantics). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchApplyExpr { + /// The gate/operation being applied (may include params, e.g., rz(pi/4)) + pub operation: Expr, + /// The targets (qubits or qubit pairs) to apply to + pub targets: Vec, + pub location: Option, +} + +/// Measurement expression: mz(T) targets or mz(pack T) targets +/// +/// Per-qubit mode (pack=false): Each qubit produces one T, count must match exactly. +/// Pack mode (pack=true): Bits fill T sequentially, T must have enough capacity. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeasureExpr { + /// The result type (e.g., u1, [4]u1, u8, Syndrome) + pub result_type: TypeExpr, + /// Whether to pack bits into the type (vs per-qubit results) + pub pack: bool, + /// The targets to measure (array literal, variable, or slice) + pub targets: Expr, + pub location: Option, +} + +/// Gate expression: gate target or gate(params) target +/// Consistent DSL-like syntax for quantum gates. +/// +/// Examples: +/// - `h q[0]` - single qubit gate +/// - `cx (q[0], q[1])` - two-qubit gate with tuple +/// - `rx(0.123) q[0]` - parameterized gate +/// - `h {q[0], q[1]}` - batch apply (set semantics) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateExpr { + /// The gate kind (H, X, CX, RZ, etc.) + pub kind: GateKind, + /// Parameters for parameterized gates (e.g., rotation angle) + pub params: Vec, + /// The target(s) - single qubit, tuple, array, or set + pub target: Expr, + pub location: Option, +} + +/// If expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IfExpr { + pub condition: Expr, + pub then_expr: Expr, + pub else_expr: Expr, + pub location: Option, +} + +/// Block expression (labeled block that returns a value). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockExpr { + pub label: String, + /// Attributes attached to this block (e.g., @attr(kind, "syndrome")) + pub attrs: Vec, + pub statements: Vec, + /// Optional trailing expression (block's return value) + pub trailing_expr: Option>, + pub location: Option, +} + +/// Comptime expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComptimeExpr { + pub inner: Expr, + pub location: Option, +} + +/// Builtin call (@import, @This, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuiltinExpr { + pub name: String, // Without the @ + pub args: Vec, + pub location: Option, +} + +/// Anonymous struct type definition. +/// `struct { x: i32, y: i32 }` creates an anonymous struct type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnonStructExpr { + pub fields: Vec, + pub is_packed: bool, + pub location: Option, +} + +/// Struct initialization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructInitExpr { + pub ty: Option, + pub fields: Vec, + pub location: Option, +} + +/// Field initializer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldInit { + pub name: String, + pub value: Expr, + pub location: Option, +} + +/// Array initialization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArrayInitExpr { + pub ty: Option, + pub elements: Vec, + pub location: Option, +} + +/// Bracket array literal: [a, b, c] +/// Used for batch quantum operations like h(&[q[0], q[1], q[2]]) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BracketArrayExpr { + pub elements: Vec, + pub location: Option, +} + +/// Tuple expression: (a, b) or (a, b, c) +/// Used for two-qubit gate pairs like cx(&[(q[0], q[1]), (q[2], q[3])]) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TupleExpr { + pub elements: Vec, + pub location: Option, +} + +/// Set expression: {a, b, c} +/// Unique unordered elements, backed by BTreeSet at runtime +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetExpr { + pub elements: Vec, + pub element_type: Option, // For empty_set: Set(T){} + pub location: Option, +} + +/// Range expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RangeExpr { + pub start: Option, + pub end: Option, + pub location: Option, +} + +/// Error value literal: error.OutOfMemory, error.InvalidArgument, etc. +/// Represents a specific error value from an error set. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorValueExpr { + pub name: String, + pub location: Option, +} + +/// Fault value literal: fault.Leakage, fault.QubitLoss, etc. +/// Represents a specific fault value from a fault set. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaultValueExpr { + pub name: String, + pub location: Option, +} + +/// Catch expression: `expr catch |err| handler` +/// Unwraps an error union, returning the payload if successful, +/// or evaluating the handler with the error if it fails. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatchExpr { + pub operand: Expr, + /// Optional capture variable for the error value + pub capture: Option, + pub handler: Expr, + pub location: Option, +} + +// ============================================================================= +// Channel Expression (Unified Side-Channel Communication) +// ============================================================================= + +/// Channel expression for side-channel communication. +/// +/// Unified syntax: `@emit.channel.command(args)` +/// +/// All channel expressions use the `@emit` prefix and have sticky/barrier semantics. +/// The behavior (elision, barriers) depends on target and channel configuration. +/// +/// Built-in channels: +/// - `@emit.log.*` - Logging (trace, debug, info, warn, error, at) +/// - `@emit.sim.*` - Simulator control (send, noise_enable, noise_disable) +/// - `@emit.hw.*` - Hardware communication (send, calibrate, etc.) +/// +/// Custom channels can be defined for instrumentation, timing, debugging, etc. +/// +/// Examples: +/// - `@emit.log.trace(f"detailed message")` +/// - `@emit.log.debug("namespace", f"message")` +/// - `@emit.log.info(f"msg", data: obj)` +/// - `@emit.sim.send("seed", 42)` +/// - `@emit.sim.noise_disable()` +/// - `@emit.hw.send("calibration", params)` +/// - `@emit.timing.send("checkpoint", t)` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelExpr { + /// Channel name (log, sim, hw, timing, debug, etc.) + pub channel: String, + /// Command name (send, trace, debug, noise_enable, etc.) + pub command: String, + /// Arguments (positional and/or named) + pub args: Vec, + pub location: Option, +} + +/// Argument to a channel command. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ChannelArg { + /// Positional argument + Positional(Expr), + /// Named argument (name: value) + Named { name: String, value: Expr }, +} + +impl ChannelArg { + /// Get the expression value of this argument. + pub fn value(&self) -> &Expr { + match self { + ChannelArg::Positional(e) => e, + ChannelArg::Named { value, .. } => value, + } + } + + /// Get the name if this is a named argument. + pub fn name(&self) -> Option<&str> { + match self { + ChannelArg::Positional(_) => None, + ChannelArg::Named { name, .. } => Some(name), + } + } +} + +// ============================================================================= +// Result Expression (Program Output Channel) +// ============================================================================= + +/// Result emission expression. +/// +/// `result(tag, value)` emits a tagged value as program output. +/// This is the primary way to return structured data from quantum programs +/// back to the caller/orchestrator. Unlike logs, results are NEVER elided. +/// +/// Examples: +/// - `result("measurement", m)` - simple result +/// - `result("qec/syndrome", syndrome)` - namespaced with / convention +/// - `result("round_1/parity", parity)` - hierarchical naming +/// +/// The tag must be a compile-time string literal (like Guppy). +/// Value can be any serializable type: int, bool, float, arrays. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultExpr { + /// The tag/key for this result (compile-time string literal) + pub tag: String, + /// The value to emit + pub value: Expr, + pub location: Option, +} + +// ============================================================================= +// Types +// ============================================================================= + +/// Type expressions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TypeExpr { + // Primitive types + Primitive(PrimitiveType), + + // Quantum types + Qubit, + Bit, + QAlloc(Option>), // qalloc or qalloc(N) + + // Compound types + Array(Box), + Pointer(Box), + Optional(Box), + ErrorUnion(Box), // E!T - single error, either/or + CollectedErrors(Box), // []E!T - collected errors, both + Fn(Box), + Tuple(Vec), + Set(Box), // Set(T) - unordered unique elements + + // Inline/anonymous struct type: struct { x: i32, y: i32 } + Struct(Box), + // Inline/anonymous enum type: enum { a, b, c } + Enum(Box), + + // Named type + Named(TypePath), + + // Special + Type, // The type `type` + AnyType, // anytype for generic params + Unit, // unit type - has exactly one value +} + +/// Primitive types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PrimitiveType { + // Arbitrary-width integers (like Zig: u1, u4, u7, u128, etc.) + UInt { bits: u16 }, // Unsigned integer with N bits + IInt { bits: u16 }, // Signed integer with N bits + Usize, // Platform-dependent unsigned size + Isize, // Platform-dependent signed size + // Floating point + F16, + F32, + F64, + F128, + A64, // Angle type (64-bit, maps to PECOS Angle64) + Bool, +} + +/// Array type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArrayType { + pub element: TypeExpr, + pub size: Option, // None for slices + pub sentinel: Option, +} + +/// Pointer type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PointerType { + pub pointee: TypeExpr, + pub is_const: bool, + pub is_many: bool, // [*] vs * + pub sentinel: Option, +} + +/// Error union type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorUnionType { + pub error_type: TypeExpr, + pub payload_type: TypeExpr, +} + +/// Collected errors type: []E!T +/// Represents "array of error E, with value T" (both, not either/or). +/// Used for QEC-style error collection where all operations execute +/// and errors are collected rather than stopping on first error. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectedErrorsType { + /// The error type (e.g., QuantumError) + pub error_type: TypeExpr, + /// The payload type (e.g., void or u1) + pub payload_type: TypeExpr, +} + +/// Function type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FnType { + pub params: Vec, + pub return_type: Option, +} + +/// Inline/anonymous struct type: struct { x: i32, y: i32 } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlineStructType { + pub fields: Vec, + pub is_packed: bool, +} + +/// Inline/anonymous enum type: enum { a, b, c } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlineEnumType { + pub variants: Vec, + pub tag_type: Option, +} + +/// Type path (e.g., `std.mem.Allocator`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypePath { + pub segments: Vec, + pub location: Option, +} + +// ============================================================================= +// Convenience Implementations +// ============================================================================= + +impl From<&str> for Ident { + fn from(name: &str) -> Self { + Ident { + name: name.to_string(), + location: None, + } + } +} + +impl From for Expr { + fn from(value: i128) -> Self { + Expr::IntLit(IntLit { + value, + suffix: None, + location: None, + }) + } +} + +impl From for Expr { + fn from(value: f64) -> Self { + Expr::FloatLit(FloatLit { + value, + suffix: None, + location: None, + }) + } +} + +impl From for Expr { + fn from(value: bool) -> Self { + Expr::BoolLit(BoolLit { + value, + location: None, + }) + } +} + +impl From for Expr { + fn from(value: String) -> Self { + Expr::StringLit(StringLit { + value, + location: None, + }) + } +} diff --git a/exp/zlup/src/build.rs b/exp/zlup/src/build.rs new file mode 100644 index 000000000..4b7b532b9 --- /dev/null +++ b/exp/zlup/src/build.rs @@ -0,0 +1,738 @@ +//! Build system for Zlup projects. +//! +//! This module implements the build.zlp execution system, following Zig's philosophy: +//! **the build system IS the language**. +//! +//! ## Overview +//! +//! Like Zig's `build.zig`, Zlup uses `build.zlp` - a Zlup program that runs at +//! compile time to configure the build. No separate DSL, no YAML, no TOML for +//! build logic - just Zlup with comptime. +//! +//! ## Usage +//! +//! ```bash +//! # Build using build.zlp +//! zlup build +//! +//! # Build with options +//! zlup build -Dnoise=true -Doptimize=release +//! +//! # Run tests +//! zlup build test +//! ``` +//! +//! ## Status +//! +//! This is Phase 1 of the build system implementation, providing the foundational +//! infrastructure. Full comptime execution of build.zlp requires additional +//! interpreter capabilities. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +// ============================================================================= +// Build Configuration +// ============================================================================= + +/// Target operating system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Os { + Linux, + MacOS, + Windows, + FreeBSD, + Native, +} + +impl Default for Os { + fn default() -> Self { + #[cfg(target_os = "linux")] + return Os::Linux; + #[cfg(target_os = "macos")] + return Os::MacOS; + #[cfg(target_os = "windows")] + return Os::Windows; + #[cfg(target_os = "freebsd")] + return Os::FreeBSD; + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "freebsd" + )))] + return Os::Native; + } +} + +/// Target CPU architecture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Arch { + X86_64, + Aarch64, + Arm, + Wasm32, + Native, +} + +impl Default for Arch { + fn default() -> Self { + #[cfg(target_arch = "x86_64")] + return Arch::X86_64; + #[cfg(target_arch = "aarch64")] + return Arch::Aarch64; + #[cfg(target_arch = "arm")] + return Arch::Arm; + #[cfg(target_arch = "wasm32")] + return Arch::Wasm32; + #[cfg(not(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "arm", + target_arch = "wasm32" + )))] + return Arch::Native; + } +} + +/// Build target specification. +#[derive(Debug, Clone, Default)] +pub struct Target { + pub os: Os, + pub arch: Arch, +} + +impl Target { + /// Create a native target (current platform). + pub fn native() -> Self { + Self::default() + } + + /// Create a specific target. + pub fn new(os: Os, arch: Arch) -> Self { + Self { os, arch } + } +} + +/// Optimization level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Optimize { + /// No optimization, fastest compilation + #[default] + Debug, + /// Optimize for speed + ReleaseFast, + /// Optimize for size + ReleaseSmall, + /// Optimize for safety (bounds checks, etc.) + ReleaseSafe, +} + +// ============================================================================= +// Build Artifacts +// ============================================================================= + +/// Unique identifier for a build step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct StepId(usize); + +/// A build step that can be executed. +#[derive(Debug, Clone)] +pub struct Step { + pub id: StepId, + pub name: String, + pub description: String, + pub dependencies: Vec, +} + +/// Options for creating an executable. +#[derive(Debug, Clone, Default)] +pub struct ExecutableOptions { + pub name: String, + pub root_source: PathBuf, + pub target: Option, + pub optimize: Option, + /// NASA Power of 10 strict mode + pub strict: bool, +} + +/// Options for creating a library. +#[derive(Debug, Clone, Default)] +pub struct LibraryOptions { + pub name: String, + pub root_source: PathBuf, + pub target: Option, + pub optimize: Option, + pub strict: bool, +} + +/// Options for creating a test. +#[derive(Debug, Clone, Default)] +pub struct TestOptions { + pub root_source: PathBuf, + pub strict: bool, +} + +/// An executable artifact. +#[derive(Debug, Clone)] +pub struct Executable { + pub name: String, + pub root_source: PathBuf, + pub target: Target, + pub optimize: Optimize, + pub strict: bool, + pub defines: BTreeMap, + pub libraries: Vec, + pub library_paths: Vec, + pub step: StepId, +} + +/// A library artifact. +#[derive(Debug, Clone)] +pub struct Library { + pub name: String, + pub root_source: PathBuf, + pub target: Target, + pub optimize: Optimize, + pub strict: bool, + pub step: StepId, +} + +/// A test artifact. +#[derive(Debug, Clone)] +pub struct Test { + pub root_source: PathBuf, + pub strict: bool, + pub step: StepId, +} + +// ============================================================================= +// Build Context +// ============================================================================= + +/// The main build context. +/// +/// This is passed to the `build` function in `build.zlp` and provides +/// the API for configuring the build. +#[derive(Debug)] +pub struct Build { + /// Project root directory + project_root: PathBuf, + /// Default target + default_target: Target, + /// Default optimization level + default_optimize: Optimize, + /// Named build steps + steps: Vec, + /// Executables to build + executables: Vec, + /// Libraries to build + libraries: Vec, + /// Tests to run + tests: Vec, + /// User-defined options from command line + options: BTreeMap, + /// Install directory + install_prefix: PathBuf, + /// Next step ID + next_step_id: usize, +} + +/// Value of a user-defined build option. +#[derive(Debug, Clone)] +pub enum OptionValue { + Bool(bool), + String(String), + Int(i64), +} + +impl Build { + /// Create a new build context. + pub fn new(project_root: impl Into) -> Self { + let project_root = project_root.into(); + let install_prefix = project_root.join("zig-out"); + + Self { + project_root, + default_target: Target::native(), + default_optimize: Optimize::Debug, + steps: Vec::new(), + executables: Vec::new(), + libraries: Vec::new(), + tests: Vec::new(), + options: BTreeMap::new(), + install_prefix, + next_step_id: 0, + } + } + + /// Get the project root directory. + pub fn project_root(&self) -> &Path { + &self.project_root + } + + /// Set a command-line option. + pub fn set_option(&mut self, name: impl Into, value: OptionValue) { + self.options.insert(name.into(), value); + } + + /// Get a boolean option. + pub fn option_bool(&self, name: &str) -> Option { + match self.options.get(name) { + Some(OptionValue::Bool(v)) => Some(*v), + _ => None, + } + } + + /// Get a string option. + pub fn option_string(&self, name: &str) -> Option<&str> { + match self.options.get(name) { + Some(OptionValue::String(v)) => Some(v), + _ => None, + } + } + + /// Get an integer option. + pub fn option_int(&self, name: &str) -> Option { + match self.options.get(name) { + Some(OptionValue::Int(v)) => Some(*v), + _ => None, + } + } + + /// Get the standard target options (from CLI or defaults). + pub fn standard_target_options(&self) -> Target { + self.default_target.clone() + } + + /// Get the standard optimization option (from CLI or defaults). + pub fn standard_optimize_option(&self) -> Optimize { + self.default_optimize + } + + /// Set the default target. + pub fn set_default_target(&mut self, target: Target) { + self.default_target = target; + } + + /// Set the default optimization level. + pub fn set_default_optimize(&mut self, optimize: Optimize) { + self.default_optimize = optimize; + } + + /// Allocate a new step ID. + fn alloc_step_id(&mut self) -> StepId { + let id = StepId(self.next_step_id); + self.next_step_id += 1; + id + } + + /// Create a new named build step. + pub fn step(&mut self, name: impl Into, description: impl Into) -> StepId { + let id = self.alloc_step_id(); + self.steps.push(Step { + id, + name: name.into(), + description: description.into(), + dependencies: Vec::new(), + }); + id + } + + /// Add a dependency between steps. + pub fn add_step_dependency(&mut self, step: StepId, depends_on: StepId) { + if let Some(s) = self.steps.iter_mut().find(|s| s.id == step) { + s.dependencies.push(depends_on); + } + } + + /// Add an executable to build. + pub fn add_executable(&mut self, options: ExecutableOptions) -> &mut Executable { + let step = self.alloc_step_id(); + let exe = Executable { + name: options.name, + root_source: self.project_root.join(&options.root_source), + target: options + .target + .unwrap_or_else(|| self.default_target.clone()), + optimize: options.optimize.unwrap_or(self.default_optimize), + strict: options.strict, + defines: BTreeMap::new(), + libraries: Vec::new(), + library_paths: Vec::new(), + step, + }; + self.executables.push(exe); + self.executables.last_mut().unwrap() + } + + /// Add a library to build. + pub fn add_library(&mut self, options: LibraryOptions) -> &mut Library { + let step = self.alloc_step_id(); + let lib = Library { + name: options.name, + root_source: self.project_root.join(&options.root_source), + target: options + .target + .unwrap_or_else(|| self.default_target.clone()), + optimize: options.optimize.unwrap_or(self.default_optimize), + strict: options.strict, + step, + }; + self.libraries.push(lib); + self.libraries.last_mut().unwrap() + } + + /// Add a test to run. + pub fn add_test(&mut self, options: TestOptions) -> &mut Test { + let step = self.alloc_step_id(); + let test = Test { + root_source: self.project_root.join(&options.root_source), + strict: options.strict, + step, + }; + self.tests.push(test); + self.tests.last_mut().unwrap() + } + + /// Mark an artifact for installation. + pub fn install_artifact(&mut self, _step: StepId) { + // In the future, this will add the artifact to the install step + } + + /// Get all executables. + pub fn executables(&self) -> &[Executable] { + &self.executables + } + + /// Get all libraries. + pub fn libraries(&self) -> &[Library] { + &self.libraries + } + + /// Get all tests. + pub fn tests(&self) -> &[Test] { + &self.tests + } + + /// Get all named steps. + pub fn steps(&self) -> &[Step] { + &self.steps + } + + /// Find a step by name. + pub fn find_step(&self, name: &str) -> Option<&Step> { + self.steps.iter().find(|s| s.name == name) + } +} + +impl Executable { + /// Add a compile-time define. + pub fn add_define(&mut self, name: impl Into, value: impl Into) { + self.defines.insert(name.into(), value.into()); + } + + /// Link a library. + pub fn link_library(&mut self, name: impl Into) { + self.libraries.push(name.into()); + } + + /// Add a library search path. + pub fn add_library_path(&mut self, path: impl Into) { + self.library_paths.push(path.into()); + } +} + +// ============================================================================= +// Build Runner +// ============================================================================= + +/// Error that can occur during build. +#[derive(Debug, Clone)] +pub struct BuildError { + pub message: String, +} + +impl std::fmt::Display for BuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "build error: {}", self.message) + } +} + +impl std::error::Error for BuildError {} + +/// Result type for build operations. +pub type BuildResult = Result; + +/// Build runner that executes build.zlp. +pub struct BuildRunner { + build: Build, +} + +impl BuildRunner { + /// Create a new build runner for the given project. + pub fn new(project_root: impl Into) -> Self { + Self { + build: Build::new(project_root), + } + } + + /// Set a command-line option. + pub fn set_option(&mut self, name: impl Into, value: OptionValue) { + self.build.set_option(name, value); + } + + /// Parse command-line options in the format `-Dname=value`. + pub fn parse_options(&mut self, args: &[String]) -> BuildResult> { + let mut remaining = Vec::new(); + + for arg in args { + if let Some(opt) = arg.strip_prefix("-D") { + if let Some((name, value)) = opt.split_once('=') { + // Try to parse as different types + if value == "true" { + self.build.set_option(name, OptionValue::Bool(true)); + } else if value == "false" { + self.build.set_option(name, OptionValue::Bool(false)); + } else if let Ok(n) = value.parse::() { + self.build.set_option(name, OptionValue::Int(n)); + } else { + self.build + .set_option(name, OptionValue::String(value.to_string())); + } + } else { + // -Dflag without value means true + self.build.set_option(opt, OptionValue::Bool(true)); + } + } else if arg == "-Doptimize=release" || arg == "--release" { + self.build.set_default_optimize(Optimize::ReleaseFast); + } else if arg == "-Doptimize=debug" || arg == "--debug" { + self.build.set_default_optimize(Optimize::Debug); + } else { + remaining.push(arg.clone()); + } + } + + Ok(remaining) + } + + /// Load and parse the build.zlp file. + pub fn load_build_file(&mut self) -> BuildResult<()> { + let build_file = self.build.project_root.join("build.zlp"); + + if !build_file.exists() { + return Err(BuildError { + message: format!( + "build.zlp not found in {}", + self.build.project_root.display() + ), + }); + } + + let source = std::fs::read_to_string(&build_file).map_err(|e| BuildError { + message: format!("failed to read build.zlp: {}", e), + })?; + + // Parse the build file + let _ast = + crate::parse_file(&source, build_file.to_string_lossy()).map_err(|e| BuildError { + message: format!("failed to parse build.zlp: {}", e), + })?; + + // TODO: Execute the build function using the comptime evaluator + // For now, we just validate that build.zlp parses correctly + + Ok(()) + } + + /// Get the build context. + pub fn build(&self) -> &Build { + &self.build + } + + /// Get mutable access to the build context. + pub fn build_mut(&mut self) -> &mut Build { + &mut self.build + } + + /// Run the default build step. + pub fn run_default(&self) -> BuildResult<()> { + // Build all executables + for exe in &self.build.executables { + self.build_executable(exe)?; + } + Ok(()) + } + + /// Run a named build step. + pub fn run_step(&self, name: &str) -> BuildResult<()> { + match name { + "test" => self.run_tests(), + _ => { + if self.build.find_step(name).is_some() { + // TODO: Execute custom step + Ok(()) + } else { + Err(BuildError { + message: format!("unknown build step: {}", name), + }) + } + } + } + } + + /// Build an executable. + fn build_executable(&self, exe: &Executable) -> BuildResult<()> { + println!("Building executable: {}", exe.name); + println!(" Source: {}", exe.root_source.display()); + println!(" Target: {:?} / {:?}", exe.target.os, exe.target.arch); + println!(" Optimize: {:?}", exe.optimize); + + if exe.strict { + println!(" Strict mode: enabled"); + } + + for (name, value) in &exe.defines { + println!(" Define: {} = {}", name, value); + } + + for lib in &exe.libraries { + println!(" Link: {}", lib); + } + + // TODO: Actually compile the executable + // This would invoke the parser, semantic analyzer, and code generator + + Ok(()) + } + + /// Run all tests. + fn run_tests(&self) -> BuildResult<()> { + use crate::test_runner::{TestOutcome, TestRunConfig, TestRunner, format_results}; + + for test in &self.build.tests { + println!("Running test: {}", test.root_source.display()); + + let source = std::fs::read_to_string(&test.root_source).map_err(|e| BuildError { + message: format!("failed to read {}: {}", test.root_source.display(), e), + })?; + + let program = crate::parser::parse(&source).map_err(|e| BuildError { + message: format!("parse error: {}", e), + })?; + + let config = TestRunConfig::default(); + let runner = TestRunner::new(config); + let results = runner.run(&program); + print!("{}", format_results(&results)); + + let has_failures = results + .iter() + .any(|r| matches!(r.outcome, TestOutcome::Fail(_))); + if has_failures { + return Err(BuildError { + message: "some tests failed".to_string(), + }); + } + } + Ok(()) + } + + /// Print available build steps. + pub fn print_help(&self) { + println!("Build steps:"); + println!(" (default) Build all targets"); + println!(" test Run all tests"); + + for step in &self.build.steps { + println!(" {:12} {}", step.name, step.description); + } + + println!(); + println!("Options:"); + println!(" -Dname=value Set a build option"); + println!(" --release Build in release mode"); + println!(" --debug Build in debug mode (default)"); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_target_native() { + let target = Target::native(); + // Should not panic + assert!(matches!( + target.os, + Os::Linux | Os::MacOS | Os::Windows | Os::FreeBSD | Os::Native + )); + } + + #[test] + fn test_build_context() { + let mut build = Build::new("/tmp/test-project"); + + build.set_option("noise", OptionValue::Bool(true)); + build.set_option("distance", OptionValue::Int(5)); + + assert_eq!(build.option_bool("noise"), Some(true)); + assert_eq!(build.option_int("distance"), Some(5)); + assert_eq!(build.option_bool("unknown"), None); + } + + #[test] + fn test_add_executable() { + let mut build = Build::new("/tmp/test-project"); + + let exe = build.add_executable(ExecutableOptions { + name: "test-exe".to_string(), + root_source: PathBuf::from("src/main.zlp"), + ..Default::default() + }); + + assert_eq!(exe.name, "test-exe"); + assert!(exe.root_source.ends_with("src/main.zlp")); + } + + #[test] + fn test_add_step() { + let mut build = Build::new("/tmp/test-project"); + + let step1 = build.step("compile", "Compile source files"); + let step2 = build.step("link", "Link object files"); + + build.add_step_dependency(step2, step1); + + assert_eq!(build.steps().len(), 2); + assert_eq!(build.find_step("compile").unwrap().name, "compile"); + } + + #[test] + fn test_parse_options() { + let mut runner = BuildRunner::new("/tmp/test"); + + let args = vec![ + "-Dnoise=true".to_string(), + "-Ddistance=5".to_string(), + "-Dname=test".to_string(), + "-Dflag".to_string(), + "--release".to_string(), + "build".to_string(), + ]; + + let remaining = runner.parse_options(&args).unwrap(); + + assert_eq!(runner.build().option_bool("noise"), Some(true)); + assert_eq!(runner.build().option_int("distance"), Some(5)); + assert_eq!(runner.build().option_string("name"), Some("test")); + assert_eq!(runner.build().option_bool("flag"), Some(true)); + assert_eq!(runner.build().default_optimize, Optimize::ReleaseFast); + assert_eq!(remaining, vec!["build"]); + } +} diff --git a/exp/zlup/src/codegen.rs b/exp/zlup/src/codegen.rs new file mode 100644 index 000000000..4ae429620 --- /dev/null +++ b/exp/zlup/src/codegen.rs @@ -0,0 +1,26 @@ +//! Code generation backends for Zluppy. +//! +//! Zluppy compiles to multiple targets: +//! - **HUGR**: Hierarchical Unified Graph Representation for experiments/hardware +//! - **SLR-AST**: JSON bridge to Python/PECOS for integration +//! - **PHIR/JSON**: JSON serialization of PECOS High-level IR for simulator targeting +//! - **QASM**: OpenQASM 2.0 for hardware execution +//! +//! ## Design Philosophy +//! +//! Same problems as Guppy, simpler idioms: +//! - Explicit over implicit +//! - Low-level but safe +//! - Predictable, bounded output + +#[cfg(feature = "hugr")] +pub mod hugr; +pub mod phir; +pub mod qasm; +pub mod slr; + +#[cfg(feature = "hugr")] +pub use hugr::{CodegenMode, HugrCodegen}; +pub use phir::{PhirJsonCodegen, PhirJsonError, PhirJsonProgram}; +pub use qasm::{QasmCodegen, QasmError}; +pub use slr::SlrCodegen; diff --git a/exp/zlup/src/codegen/hugr.rs b/exp/zlup/src/codegen/hugr.rs new file mode 100644 index 000000000..435cab614 --- /dev/null +++ b/exp/zlup/src/codegen/hugr.rs @@ -0,0 +1,2670 @@ +//! HUGR code generation for Zluppy. +//! +//! This module generates HUGR (Hierarchical Unified Graph Representation) from +//! Zluppy AST. HUGR is used for targeting experiments and quantum hardware. +//! +//! ## Design +//! +//! The codegen walks the Zluppy AST and: +//! 1. Collects allocator declarations to determine qubit counts +//! 2. Maps gate calls to TketOp operations +//! 3. Tracks wire flow through the circuit +//! 4. Handles rotation angles (converted to half-turns) +//! +//! ## Wire Tracking +//! +//! In HUGR, each qubit is represented by a Wire that flows through the graph. +//! When a gate operates on a qubit, it consumes the input wire and produces +//! a new output wire. We maintain a mapping from qubit identifiers to their +//! current wire. + +use std::collections::BTreeMap; +use thiserror::Error; + +use std::io::Cursor; + +use tket::TketOp; +use tket::extension::bool::bool_type; +use tket::hugr::builder::{ + BuildError, DFGBuilder, Dataflow, DataflowHugr, DataflowSubContainer, SubContainer, +}; +use tket::hugr::envelope::EnvelopeConfig; +use tket::hugr::extension::prelude::qb_t; +use tket::hugr::types::Signature; +use tket::hugr::{Hugr, Wire, type_row}; + +use crate::ast::{ + BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, IndexExpr, Program, Stmt, + TopLevelDecl, +}; + +// ============================================================================= +// Errors +// ============================================================================= + +/// HUGR code generation errors. +#[derive(Debug, Error)] +pub enum HugrError { + #[error("unknown gate '{name}'")] + UnknownGate { name: String }, + + #[error("undefined qubit '{name}'")] + UndefinedQubit { name: String }, + + #[error("qubit index {index} out of bounds for allocator with capacity {capacity}")] + QubitIndexOutOfBounds { index: usize, capacity: usize }, + + #[error("expected {expected} arguments for gate '{gate}', got {got}")] + WrongArgumentCount { + gate: String, + expected: usize, + got: usize, + }, + + #[error("allocator '{name}' not found")] + AllocatorNotFound { name: String }, + + #[error("HUGR builder error: {0}")] + BuilderError(String), + + #[error("unsupported expression in codegen")] + UnsupportedExpression, + + #[error("rotation angle must be a numeric literal")] + InvalidRotationAngle, + + #[error("HUGR serialization error: {0}")] + SerializationError(String), +} + +/// Result type for HUGR code generation. +pub type HugrResult = Result; + +// ============================================================================= +// Qubit Tracking +// ============================================================================= + +/// Tracks an allocator and its qubits. +#[derive(Debug, Clone)] +pub struct Allocator { + /// Name of the allocator variable. + pub name: String, + /// Capacity (number of qubits). + pub capacity: usize, + /// Starting index in the global qubit array. + pub start_index: usize, +} + +/// Tracks a qubit reference (allocator + index). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct QubitRef { + /// Allocator name. + pub allocator: String, + /// Index within the allocator. + pub index: usize, +} + +impl QubitRef { + pub fn new(allocator: impl Into, index: usize) -> Self { + Self { + allocator: allocator.into(), + index, + } + } +} + +// ============================================================================= +// Gate Mapping +// ============================================================================= + +/// Result of mapping a gate name - either a direct TketOp or a composite gate. +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug, Clone)] +enum GateMapping { + /// Direct mapping to a TketOp + Direct(TketOp), + /// SWAP gate (decomposed to 3 CX gates) + Swap, + /// iSWAP gate (decomposed) + ISwap, + /// SY gate (sqrt of Y) - implemented as Ry(π/2) + SY, + /// SYdg gate (sqrt of Y dagger) - implemented as Ry(-π/2) + SYdg, + /// CH gate (controlled Hadamard) - decomposed to Ry(π/4) CZ Ry(-π/4) + CH, + /// SXX gate (sqrt of XX Ising) - decomposed + SXX, + /// SYY gate (sqrt of YY Ising) - decomposed + SYY, + /// SZZ gate (sqrt of ZZ Ising) - decomposed to CX S CX + SZZ, + /// Dagger versions of Ising gates + SXXdg, + SYYdg, + SZZdg, + /// RZZ gate (ZZ rotation) - decomposed to CX Rz CX + RZZ, + /// F gate (Clifford face rotation) - decomposed to H Sdg H Sdg + F, + /// F dagger - decomposed to S H S H + Fdg, + /// F4 gate (fourth root of face rotation) - decomposed + F4, + /// F4 dagger + F4dg, + /// Mid-circuit measurement (returns classical bit, keeps qubit) + MidMeasure, +} + +/// Maps Zluppy gate names to gate operations. +/// +/// Zluppy uses lowercase gate names following Zig-style conventions. +/// All gate names are lowercase. +/// +/// Available gates: +/// - Single-qubit Pauli: h, x, y, z +/// - Square root: sx, sxdg, sy, sydg, sz, szdg (sqrt of X, Y, and Z) +/// - T gates: t, tdg (fourth root of Z) +/// - F gates: f, fdg, f4, f4dg (Clifford face rotations) +/// - Rotation: rx, ry, rz (single-qubit), crz, rzz (two-qubit) +/// - Two-qubit: cx, cy, cz, ch, swap, iswap +/// - Two-qubit Ising: sxx, syy, szz, sxxdg, syydg, szzdg +/// - Three-qubit: ccx +/// - Measurement: mz (Z-basis measurement) +/// - State preparation: pz (prepare +Z eigenstate) +/// +/// Composite gates (decomposed): +/// - swap: cx(a,b) cx(b,a) cx(a,b) +/// - iswap: sz(a) sz(b) h(a) cx(a,b) cx(b,a) h(b) +/// - sy: ry(π/2) +/// - sydg: ry(-π/2) +/// - ch: ry(π/4, b) cz(a,b) ry(-π/4, b) +/// - szz: cx(a,b) sz(b) cx(a,b) +/// - sxx: h(a) h(b) szz(a,b) h(a) h(b) +/// - syy: sxdg(a) sxdg(b) szz(a,b) sx(a) sx(b) +/// - rzz(θ): cx(a,b) rz(θ, b) cx(a,b) +/// - f: h sdg h sdg (Clifford: X→Y→Z→X) +/// - fdg: s h s h +fn gate_name_to_mapping(name: &str) -> Option { + match name { + // Single-qubit Pauli gates + "h" => Some(GateMapping::Direct(TketOp::H)), + "x" => Some(GateMapping::Direct(TketOp::X)), + "y" => Some(GateMapping::Direct(TketOp::Y)), + "z" => Some(GateMapping::Direct(TketOp::Z)), + + // Square root gates (sx = sqrt(X), sy = sqrt(Y), sz = sqrt(Z)) + "sx" => Some(GateMapping::Direct(TketOp::V)), // V = sqrt(X) + "sxdg" => Some(GateMapping::Direct(TketOp::Vdg)), + "sy" => Some(GateMapping::SY), // sqrt(Y) = Ry(π/2) + "sydg" => Some(GateMapping::SYdg), // sqrt(Y)† = Ry(-π/2) + "sz" => Some(GateMapping::Direct(TketOp::S)), // S = sqrt(Z) + "szdg" => Some(GateMapping::Direct(TketOp::Sdg)), + + // T gates (fourth root of Z) + "t" => Some(GateMapping::Direct(TketOp::T)), + "tdg" => Some(GateMapping::Direct(TketOp::Tdg)), + + // Rotation gates (require angle parameter) + "rx" => Some(GateMapping::Direct(TketOp::Rx)), + "ry" => Some(GateMapping::Direct(TketOp::Ry)), + "rz" => Some(GateMapping::Direct(TketOp::Rz)), + + // Two-qubit gates + "cx" => Some(GateMapping::Direct(TketOp::CX)), + "cy" => Some(GateMapping::Direct(TketOp::CY)), + "cz" => Some(GateMapping::Direct(TketOp::CZ)), + "ch" => Some(GateMapping::CH), // Controlled Hadamard (decomposed) + "crz" => Some(GateMapping::Direct(TketOp::CRz)), + "rzz" => Some(GateMapping::RZZ), // ZZ rotation (decomposed) + + // Two-qubit Ising gates (decomposed) + "sxx" => Some(GateMapping::SXX), + "syy" => Some(GateMapping::SYY), + "szz" => Some(GateMapping::SZZ), + "sxxdg" => Some(GateMapping::SXXdg), + "syydg" => Some(GateMapping::SYYdg), + "szzdg" => Some(GateMapping::SZZdg), + + // Composite two-qubit gates (decomposed) + "swap" => Some(GateMapping::Swap), + "iswap" => Some(GateMapping::ISwap), + + // Three-qubit gates + "ccx" => Some(GateMapping::Direct(TketOp::Toffoli)), + + // F gates (Clifford face rotations, decomposed) + "f" => Some(GateMapping::F), + "fdg" => Some(GateMapping::Fdg), + "f4" => Some(GateMapping::F4), + "f4dg" => Some(GateMapping::F4dg), + + // Mid-circuit measurement in Z basis (keeps qubit alive) + "mz" => Some(GateMapping::MidMeasure), + + // Prepare +Z eigenstate (reset) + "pz" => Some(GateMapping::Direct(TketOp::Reset)), + + _ => None, + } +} + +/// Returns the number of qubit operands for a gate. +fn gate_qubit_count(op: &TketOp) -> usize { + match op { + // Single-qubit gates + TketOp::H + | TketOp::X + | TketOp::Y + | TketOp::Z + | TketOp::S + | TketOp::Sdg + | TketOp::T + | TketOp::Tdg + | TketOp::V + | TketOp::Vdg + | TketOp::Rx + | TketOp::Ry + | TketOp::Rz + | TketOp::Measure + | TketOp::MeasureFree + | TketOp::Reset + | TketOp::QFree => 1, + + // Two-qubit gates + TketOp::CX | TketOp::CY | TketOp::CZ | TketOp::CRz => 2, + + // Three-qubit gates + TketOp::Toffoli => 3, + + // Zero-qubit gates (allocation) + TketOp::QAlloc | TketOp::TryQAlloc => 0, + + // Default for any future variants + _ => 1, + } +} + +/// Returns whether a gate requires a rotation angle parameter. +fn gate_needs_angle(op: &TketOp) -> bool { + matches!(op, TketOp::Rx | TketOp::Ry | TketOp::Rz | TketOp::CRz) +} + +// ============================================================================= +// Code Generator Configuration +// ============================================================================= + +/// Controls how composite gates are handled during code generation. +/// +/// When targeting real hardware or HUGR-native execution, use `Decompose` to +/// break down gates like SWAP and iSWAP into primitive operations. +/// +/// When targeting simulation (e.g., PECOS), use `Native` to emit the gates +/// directly if the simulator supports them natively. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CodegenMode { + /// Decompose composite gates into primitives (e.g., SWAP → 3 CX gates). + /// Use this for hardware targets or HUGR-only execution. + #[default] + Decompose, + + /// Emit gates natively without decomposition. + /// Use this for simulation backends that support composite gates. + /// + /// Note: Currently iSWAP and SWAP are always decomposed since HUGR's + /// tket extension doesn't have native support for them. This mode + /// affects future gates where we might have both options. + Native, +} + +// ============================================================================= +// Code Generator +// ============================================================================= + +/// HUGR code generator. +/// +/// Walks a Zluppy AST and produces a HUGR graph. +pub struct HugrCodegen { + /// Code generation mode. + mode: CodegenMode, + /// Allocators by name. + allocators: BTreeMap, + /// Total number of qubits across all allocators. + total_qubits: usize, + /// Collected gate operations. + operations: Vec, + /// Names of classical variables (from measurement results). + classical_vars: std::collections::BTreeSet, +} + +/// A gate operation to be compiled. +#[derive(Debug, Clone)] +enum GateOp { + /// A direct TketOp gate. + Direct { + op: TketOp, + qubits: Vec, + angle: Option, + }, + /// SWAP gate (will be decomposed to 3 CX gates). + Swap { + qubit_a: QubitRef, + qubit_b: QubitRef, + }, + /// iSWAP gate (will be decomposed). + ISwap { + qubit_a: QubitRef, + qubit_b: QubitRef, + }, + /// Mid-circuit measurement (keeps qubit, stores result). + MidMeasure { + qubit: QubitRef, + /// Name of the classical variable to store the result. + result_var: String, + }, + /// Conditional block based on classical measurement result. + Conditional { + /// Name of the classical variable to condition on. + condition_var: String, + /// Operations to execute if condition is true. + then_ops: Vec, + /// Operations to execute if condition is false. + else_ops: Vec, + }, +} + +impl HugrCodegen { + /// Create a new HUGR code generator with default settings. + /// + /// Uses `CodegenMode::Decompose` by default, which breaks down composite + /// gates into primitives for maximum compatibility. + pub fn new() -> Self { + Self { + mode: CodegenMode::default(), + allocators: BTreeMap::new(), + total_qubits: 0, + operations: Vec::new(), + classical_vars: std::collections::BTreeSet::new(), + } + } + + /// Create a new HUGR code generator with the specified mode. + /// + /// # Example + /// ```ignore + /// let codegen = HugrCodegen::with_mode(CodegenMode::Native); + /// ``` + pub fn with_mode(mode: CodegenMode) -> Self { + Self { + mode, + allocators: BTreeMap::new(), + total_qubits: 0, + operations: Vec::new(), + classical_vars: std::collections::BTreeSet::new(), + } + } + + /// Get the current codegen mode. + pub fn mode(&self) -> CodegenMode { + self.mode + } + + /// Set the codegen mode. + pub fn set_mode(&mut self, mode: CodegenMode) { + self.mode = mode; + } + + /// Compile a Zluppy program to HUGR. + pub fn compile(&mut self, program: &Program) -> HugrResult { + // Phase 1: Collect allocators and operations + self.collect_program(program)?; + + // Phase 2: Build HUGR + self.build_hugr() + } + + /// Compile a function to HUGR. + pub fn compile_function(&mut self, fn_decl: &FnDecl) -> HugrResult { + // Collect from function body + self.collect_block(&fn_decl.body)?; + + // Build HUGR + self.build_hugr() + } + + // ========================================================================= + // Collection Phase + // ========================================================================= + + fn collect_program(&mut self, program: &Program) -> HugrResult<()> { + for decl in &program.declarations { + self.collect_top_level(decl)?; + } + Ok(()) + } + + fn collect_top_level(&mut self, decl: &TopLevelDecl) -> HugrResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) + // Only collect from main function for now + if fn_decl.name == "main" => { + self.collect_block(&fn_decl.body)?; + } + TopLevelDecl::Binding(binding) => { + self.collect_binding(binding)?; + } + _ => {} + } + Ok(()) + } + + fn collect_binding(&mut self, binding: &Binding) -> HugrResult<()> { + // Check if this is an allocator declaration + if let Some(ref value) = binding.value { + if let Some(capacity) = self.try_extract_allocator(value) { + let start_index = self.total_qubits; + self.total_qubits += capacity; + self.allocators.insert( + binding.name.clone(), + Allocator { + name: binding.name.clone(), + capacity, + start_index, + }, + ); + } + // Check for child allocator: mut q := base.child(2) + else if let Some((parent, size)) = self.try_extract_child_allocator(value) { + // Child allocators share qubits with parent + // For now, treat them as new allocations (simplification) + let start_index = self.total_qubits; + self.total_qubits += size; + self.allocators.insert( + binding.name.clone(), + Allocator { + name: binding.name.clone(), + capacity: size, + start_index, + }, + ); + // Suppress unused variable warning + let _ = parent; + } + // Check for measurement assignment: mut result := M(q[0]) + else if self.is_measurement_call(value) { + // Track this as a classical variable + self.classical_vars.insert(binding.name.clone()); + // Collect the measurement with the variable name + self.collect_measurement_assignment(value, &binding.name)?; + } + } + Ok(()) + } + + /// Check if an expression is a measurement call. + fn is_measurement_call(&self, expr: &Expr) -> bool { + if let Expr::Call(call) = expr + && let Ok(name) = self.extract_call_name(&call.callee) + { + return name.as_str() == "mz"; + } + false + } + + /// Collect a measurement assignment: mut result := mz(u1) q[0] or mz(u1) [q[0], q[1]] + fn collect_measurement_assignment(&mut self, expr: &Expr, result_var: &str) -> HugrResult<()> { + if let Expr::Call(call) = expr { + // New typed measurement syntax: mz(type, target) + if call.args.len() == 2 { + // First arg is type (ignored for HUGR), second is target(s) + let target_arg = &call.args[1]; + let qubits = self.extract_measurement_targets(target_arg)?; + + for (i, qubit) in qubits.into_iter().enumerate() { + let var_name = if i == 0 { + result_var.to_string() + } else { + format!("{}_{}", result_var, i) + }; + self.operations.push(GateOp::MidMeasure { + qubit, + result_var: var_name, + }); + } + return Ok(()); + } + + // Legacy single-arg syntax: mz(q[0]) + if call.args.len() == 1 { + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::MidMeasure { + qubit, + result_var: result_var.to_string(), + }); + return Ok(()); + } + + return Err(HugrError::WrongArgumentCount { + gate: "mz".to_string(), + expected: 2, + got: call.args.len(), + }); + } + Ok(()) + } + + /// Extract measurement targets from an expression. + /// Handles both single qubit (q[0]) and array (&[q[0], q[1]]) syntax. + fn extract_measurement_targets(&self, expr: &Expr) -> HugrResult> { + match expr { + // Single qubit: q[0] + Expr::Index(index_expr) => { + let qubit = self.extract_qubit_from_index(index_expr)?; + Ok(vec![qubit]) + } + // Array of qubits: &[q[0], q[1]] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand + { + let mut qubits = Vec::new(); + for elem in &arr.elements { + let qubit = self.extract_qubit_ref(elem)?; + qubits.push(qubit); + } + return Ok(qubits); + } + Err(HugrError::UnsupportedExpression) + } + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Check if an expression is a batch literal (set or address-of array). + fn is_batch_literal(&self, expr: &Expr) -> bool { + match expr { + Expr::Set(_) => true, + Expr::Unary(unary) => { + matches!(unary.op, crate::ast::UnaryOp::AddrOf) + && matches!(unary.operand, Expr::BracketArray(_)) + } + _ => false, + } + } + + /// Extract single-qubit batch targets from set literal or address-of array. + /// Supports: [q[0], q[1], q[2] or &[q[0], q[1], q[2]] + fn extract_batch_single_targets(&self, expr: &Expr) -> HugrResult> { + match expr { + Expr::Set(set) => { + let mut qubits = Vec::new(); + for elem in &set.elements { + let qubit = self.extract_qubit_ref(elem)?; + qubits.push(qubit); + } + Ok(qubits) + } + // Address-of array: &[q[0], q[1], q[2]] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand + { + let mut qubits = Vec::new(); + for elem in &arr.elements { + let qubit = self.extract_qubit_ref(elem)?; + qubits.push(qubit); + } + return Ok(qubits); + } + // Single qubit (not batch) + let qubit = self.extract_qubit_ref(expr)?; + Ok(vec![qubit]) + } + // Single qubit (not batch) + _ => { + let qubit = self.extract_qubit_ref(expr)?; + Ok(vec![qubit]) + } + } + } + + /// Extract two-qubit batch targets from set or address-of array of tuples. + /// Supports: {(q[0], q[1]), (q[2], q[3])} or &[(q[0], q[1]), (q[2], q[3])] + fn extract_batch_pair_targets(&self, expr: &Expr) -> HugrResult> { + match expr { + Expr::Set(set) => { + let mut pairs = Vec::new(); + for elem in &set.elements { + let pair = self.extract_qubit_pair(elem)?; + pairs.push(pair); + } + Ok(pairs) + } + // Address-of array: &[(q[0], q[1]), (q[2], q[3])] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand + { + let mut pairs = Vec::new(); + for elem in &arr.elements { + let pair = self.extract_qubit_pair(elem)?; + pairs.push(pair); + } + return Ok(pairs); + } + Err(HugrError::UnsupportedExpression) + } + // Single pair (not batch) - could be tuple or two separate args + Expr::Tuple(tuple) if tuple.elements.len() == 2 => { + let pair = self.extract_qubit_pair(expr)?; + Ok(vec![pair]) + } + // Not a batch - will be handled by regular two-qubit gate logic + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Extract a qubit pair from a tuple expression: (q[0], q[1]) + fn extract_qubit_pair(&self, expr: &Expr) -> HugrResult<(QubitRef, QubitRef)> { + match expr { + Expr::Tuple(tuple) => { + if tuple.elements.len() != 2 { + return Err(HugrError::UnsupportedExpression); + } + let qubit_a = self.extract_qubit_ref(&tuple.elements[0])?; + let qubit_b = self.extract_qubit_ref(&tuple.elements[1])?; + Ok((qubit_a, qubit_b)) + } + _ => Err(HugrError::UnsupportedExpression), + } + } + + fn collect_block(&mut self, block: &Block) -> HugrResult<()> { + for stmt in &block.statements { + self.collect_stmt(stmt)?; + } + Ok(()) + } + + fn collect_stmt(&mut self, stmt: &Stmt) -> HugrResult<()> { + match stmt { + Stmt::Binding(binding) => self.collect_binding(binding)?, + Stmt::Expr(expr_stmt) => self.collect_expr(&expr_stmt.expr)?, + // Tick blocks - flatten operations (HUGR doesn't have native parallel blocks) + Stmt::Tick(tick_stmt) => { + for inner_stmt in &tick_stmt.body { + self.collect_stmt(inner_stmt)?; + } + } + Stmt::If(if_stmt) => { + // Check if the condition is a classical variable (from measurement) + if let Some(condition_var) = + self.try_extract_classical_condition(&if_stmt.condition) + { + // Collect operations for both branches separately + let then_ops = self.collect_block_ops(&if_stmt.then_body)?; + let else_ops = if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_ops(else_branch)? + } else { + Vec::new() + }; + + self.operations.push(GateOp::Conditional { + condition_var, + then_ops, + else_ops, + }); + } else { + // Non-classical conditional - just collect operations from both branches + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + } + Stmt::For(for_stmt) => { + self.collect_block(&for_stmt.body)?; + } + Stmt::Block(block) => self.collect_block(block)?, + _ => {} + } + Ok(()) + } + + /// Try to extract a classical variable name from a condition expression. + /// Returns Some(var_name) if the condition is a simple reference to a classical variable. + fn try_extract_classical_condition(&self, expr: &Expr) -> Option { + if let Expr::Ident(ident) = expr + && self.classical_vars.contains(&ident.name) + { + return Some(ident.name.clone()); + } + None + } + + /// Collect operations from a block into a separate Vec (for conditional branches). + fn collect_block_ops(&mut self, block: &Block) -> HugrResult> { + // Save current operations + let saved_ops = std::mem::take(&mut self.operations); + + // Collect into fresh operations list + self.collect_block(block)?; + + // Swap back and return the collected ops + let collected = std::mem::replace(&mut self.operations, saved_ops); + Ok(collected) + } + + /// Collect operations from an else branch. + fn collect_else_ops(&mut self, branch: &ElseBranch) -> HugrResult> { + match branch { + ElseBranch::Else(block) => self.collect_block_ops(block), + ElseBranch::ElseIf(if_stmt) => { + // For else-if, treat as nested conditional (simplified for now) + let saved_ops = std::mem::take(&mut self.operations); + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + let collected = std::mem::replace(&mut self.operations, saved_ops); + Ok(collected) + } + } + } + + fn collect_else_branch(&mut self, branch: &ElseBranch) -> HugrResult<()> { + match branch { + ElseBranch::Else(block) => self.collect_block(block)?, + ElseBranch::ElseIf(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + } + Ok(()) + } + + fn collect_expr(&mut self, expr: &Expr) -> HugrResult<()> { + match expr { + Expr::Call(call) => self.collect_call(call)?, + Expr::Binary(binary) => { + self.collect_expr(&binary.left)?; + self.collect_expr(&binary.right)?; + } + _ => {} + } + Ok(()) + } + + fn collect_call(&mut self, call: &CallExpr) -> HugrResult<()> { + // Check if this is a gate call + let name = self.extract_call_name(&call.callee)?; + + // Skip non-gate calls + let Some(mapping) = gate_name_to_mapping(&name) else { + // Could be a method call like child() + return Ok(()); + }; + + match mapping { + GateMapping::Direct(op) => { + // Extract qubit operands + let qubit_count = gate_qubit_count(&op); + let needs_angle = gate_needs_angle(&op); + + // For rotation gates, angle comes first (angle-first syntax) + let (angle, qubit_start) = if needs_angle { + (Some(self.extract_angle(&call.args[0])?), 1) + } else { + (None, 0) + }; + + // Check for batch operations with set or array literals + let qubit_args = &call.args[qubit_start..]; + + // Single-qubit gate with batch: h([q[0], q[1]) or h(&[q[0], q[1]]) + if qubit_count == 1 + && qubit_args.len() == 1 + && self.is_batch_literal(&qubit_args[0]) + { + let targets = self.extract_batch_single_targets(&qubit_args[0])?; + for qubit in targets { + self.operations.push(GateOp::Direct { + op, + qubits: vec![qubit], + angle, + }); + } + return Ok(()); + } + + // Two-qubit gate with batch: cx({(q[0], q[1])}) or cx(&[(q[0], q[1])]) + if qubit_count == 2 + && qubit_args.len() == 1 + && self.is_batch_literal(&qubit_args[0]) + { + let pairs = self.extract_batch_pair_targets(&qubit_args[0])?; + for (qubit_a, qubit_b) in pairs { + self.operations.push(GateOp::Direct { + op, + qubits: vec![qubit_a, qubit_b], + angle, + }); + } + return Ok(()); + } + + // Standard non-batch case + let expected_args = if needs_angle { + qubit_count + 1 + } else { + qubit_count + }; + + if call.args.len() != expected_args { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: expected_args, + got: call.args.len(), + }); + } + + // Extract qubit references (after angle if present) + let mut qubits = Vec::with_capacity(qubit_count); + for arg in call.args.iter().skip(qubit_start).take(qubit_count) { + let qubit_ref = self.extract_qubit_ref(arg)?; + qubits.push(qubit_ref); + } + + self.operations.push(GateOp::Direct { op, qubits, angle }); + } + + GateMapping::Swap => { + // SWAP requires exactly 2 qubit arguments + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Swap { qubit_a, qubit_b }); + } + + GateMapping::ISwap => { + // iSWAP requires exactly 2 qubit arguments + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::ISwap { qubit_a, qubit_b }); + } + + GateMapping::SY => { + // SY (sqrt of Y) requires exactly 1 qubit argument + // Decomposed to Ry(π/2) + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit], + angle: Some(std::f64::consts::FRAC_PI_2), + }); + } + + GateMapping::SYdg => { + // SYdg (sqrt of Y dagger) requires exactly 1 qubit argument + // Decomposed to Ry(-π/2) + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit], + angle: Some(-std::f64::consts::FRAC_PI_2), + }); + } + + GateMapping::CH => { + // CH (controlled Hadamard) requires exactly 2 qubit arguments + // Decomposed to: Ry(π/4, b) CZ(a,b) Ry(-π/4, b) + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + // Ry(π/4) on target + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit_b.clone()], + angle: Some(std::f64::consts::FRAC_PI_4), + }); + // CZ(control, target) + self.operations.push(GateOp::Direct { + op: TketOp::CZ, + qubits: vec![qubit_a, qubit_b.clone()], + angle: None, + }); + // Ry(-π/4) on target + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit_b], + angle: Some(-std::f64::consts::FRAC_PI_4), + }); + } + + GateMapping::SZZ => { + // SZZ (sqrt of ZZ Ising) requires exactly 2 qubit arguments + // Decomposed to: CX(a,b) S(b) CX(a,b) + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a, qubit_b], + angle: None, + }); + } + + GateMapping::SZZdg => { + // SZZdg (sqrt of ZZ Ising dagger) - use Sdg instead of S + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a, qubit_b], + angle: None, + }); + } + + GateMapping::SXX => { + // SXX (sqrt of XX Ising) requires exactly 2 qubit arguments + // Decomposed to: H(a) H(b) SZZ(a,b) H(a) H(b) + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + // H(a) H(b) + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_a.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_b.clone()], + angle: None, + }); + // SZZ decomposition inline: CX S CX + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + // H(a) H(b) + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_a], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_b], + angle: None, + }); + } + + GateMapping::SXXdg => { + // SXXdg - same as SXX but use Sdg instead of S + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_a.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_a], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_b], + angle: None, + }); + } + + GateMapping::SYY => { + // SYY (sqrt of YY Ising) requires exactly 2 qubit arguments + // Decomposed to: Vdg(a) Vdg(b) SZZ(a,b) V(a) V(b) + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + // Vdg(a) Vdg(b) - SXdg gates + self.operations.push(GateOp::Direct { + op: TketOp::Vdg, + qubits: vec![qubit_a.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Vdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + // SZZ decomposition inline: CX S CX + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + // V(a) V(b) - SX gates + self.operations.push(GateOp::Direct { + op: TketOp::V, + qubits: vec![qubit_a], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::V, + qubits: vec![qubit_b], + angle: None, + }); + } + + GateMapping::SYYdg => { + // SYYdg - same as SYY but use Sdg instead of S + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Direct { + op: TketOp::Vdg, + qubits: vec![qubit_a.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Vdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::V, + qubits: vec![qubit_a], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::V, + qubits: vec![qubit_b], + angle: None, + }); + } + + GateMapping::RZZ => { + // RZZ (ZZ rotation) requires 1 angle + 2 qubit arguments (angle-first) + // Decomposed to: CX(a,b) Rz(θ, b) CX(a,b) + if call.args.len() != 3 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 3, + got: call.args.len(), + }); + } + // Angle-first: rzz(angle, qubit_a, qubit_b) + let angle = self.extract_angle(&call.args[0])?; + let qubit_a = self.extract_qubit_ref(&call.args[1])?; + let qubit_b = self.extract_qubit_ref(&call.args[2])?; + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Rz, + qubits: vec![qubit_b.clone()], + angle: Some(angle), + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a, qubit_b], + angle: None, + }); + } + + GateMapping::F => { + // F gate (Clifford face rotation) requires exactly 1 qubit argument + // Decomposed to: H Sdg H Sdg + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit], + angle: None, + }); + } + + GateMapping::Fdg => { + // Fdg gate (F dagger) - S H S H + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit], + angle: None, + }); + } + + GateMapping::F4 => { + // F4 gate (fourth root of F) - approximated with T gates + // F4 ≈ Ry(π/4) Rz(π/4) + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit.clone()], + angle: Some(std::f64::consts::FRAC_PI_4), + }); + self.operations.push(GateOp::Direct { + op: TketOp::Rz, + qubits: vec![qubit], + angle: Some(std::f64::consts::FRAC_PI_4), + }); + } + + GateMapping::F4dg => { + // F4dg gate (fourth root of F dagger) - reverse of F4 + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::Rz, + qubits: vec![qubit.clone()], + angle: Some(-std::f64::consts::FRAC_PI_4), + }); + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit], + angle: Some(-std::f64::consts::FRAC_PI_4), + }); + } + + GateMapping::MidMeasure => { + // Typed measurement: mz(type, target) or mz(type, &[targets]) + // Also supports legacy mz(qubit) + if call.args.len() == 2 { + // New typed syntax: mz(type, target) + let target_arg = &call.args[1]; + let qubits = self.extract_measurement_targets(target_arg)?; + for qubit in qubits { + let result_var = format!("__measure_{}", self.operations.len()); + self.operations + .push(GateOp::MidMeasure { qubit, result_var }); + } + } else if call.args.len() == 1 { + // Legacy syntax: mz(qubit) + let qubit = self.extract_qubit_ref(&call.args[0])?; + let result_var = format!("__measure_{}", self.operations.len()); + self.operations + .push(GateOp::MidMeasure { qubit, result_var }); + } else { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + } + } + + Ok(()) + } + + // ========================================================================= + // Extraction Helpers + // ========================================================================= + + /// Try to extract allocator capacity from qalloc(n) call. + fn try_extract_allocator(&self, expr: &Expr) -> Option { + if let Expr::Call(call) = expr { + let name = self.extract_call_name(&call.callee).ok()?; + if name == "qalloc" && call.args.len() == 1 { + return self.extract_integer(&call.args[0]).ok(); + } + } + None + } + + /// Try to extract child allocator from base.child(n) call. + fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { + if let Expr::Call(call) = expr { + // Check for method call pattern: expr.child(n) + if let Expr::Field(field) = &call.callee + && field.field == "child" + && call.args.len() == 1 + { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } + } + None + } + + /// Extract the name from a call expression's callee. + fn extract_call_name(&self, callee: &Expr) -> HugrResult { + match callee { + Expr::Ident(ident) => Ok(ident.name.clone()), + // Method call: q.child(n) -> "child" + Expr::Field(field) => Ok(field.field.clone()), + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Extract an identifier from an expression. + fn extract_identifier(&self, expr: &Expr) -> HugrResult { + match expr { + Expr::Ident(ident) => Ok(ident.name.clone()), + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Extract a qubit reference from an expression (e.g., q[0]). + fn extract_qubit_ref(&self, expr: &Expr) -> HugrResult { + match expr { + Expr::Index(index_expr) => self.extract_qubit_from_index(index_expr), + _ => Err(HugrError::UnsupportedExpression), + } + } + + fn extract_qubit_from_index(&self, index: &IndexExpr) -> HugrResult { + let allocator = self.extract_identifier(&index.object)?; + let idx = self.extract_integer(&index.index)?; + + // Validate the allocator exists + let alloc = + self.allocators + .get(&allocator) + .ok_or_else(|| HugrError::AllocatorNotFound { + name: allocator.clone(), + })?; + + // Validate index is in bounds + if idx >= alloc.capacity { + return Err(HugrError::QubitIndexOutOfBounds { + index: idx, + capacity: alloc.capacity, + }); + } + + Ok(QubitRef::new(allocator, idx)) + } + + /// Extract an integer from an expression. + fn extract_integer(&self, expr: &Expr) -> HugrResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value as usize), + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Extract a rotation angle in radians from an expression. + fn extract_angle(&self, expr: &Expr) -> HugrResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value as f64), + Expr::FloatLit(lit) => Ok(lit.value), + // Handle expressions like PI / 4 + Expr::Binary(binary) => { + let left = self.extract_angle(&binary.left)?; + let right = self.extract_angle(&binary.right)?; + match binary.op { + BinaryOp::Div => Ok(left / right), + BinaryOp::Mul => Ok(left * right), + BinaryOp::Add => Ok(left + right), + BinaryOp::Sub => Ok(left - right), + _ => Err(HugrError::InvalidRotationAngle), + } + } + // Handle PI constant + Expr::Ident(ident) if ident.name == "PI" || ident.name == "pi" => { + Ok(std::f64::consts::PI) + } + Expr::Ident(ident) if ident.name == "TAU" || ident.name == "tau" => { + Ok(std::f64::consts::TAU) + } + _ => Err(HugrError::InvalidRotationAngle), + } + } + + // ========================================================================= + // HUGR Building Phase + // ========================================================================= + + fn build_hugr(&self) -> HugrResult { + if self.total_qubits == 0 { + // Empty circuit - create minimal HUGR + return self.build_empty_hugr(); + } + + // Create signature: no inputs, N bool outputs (measurement results) + let bool_row: Vec<_> = (0..self.total_qubits).map(|_| bool_type()).collect(); + let signature = Signature::new(vec![], bool_row); + + // Create builder + let mut builder = + DFGBuilder::new(signature).map_err(|e| HugrError::BuilderError(e.to_string()))?; + + // Allocate qubits using QAlloc + let mut qubit_wires: BTreeMap = BTreeMap::new(); + for (name, alloc) in &self.allocators { + for i in 0..alloc.capacity { + let qubit_ref = QubitRef::new(name.clone(), i); + // Add QAlloc operation to allocate a qubit + let qalloc_wire: Wire = builder + .add_dataflow_op(TketOp::QAlloc, vec![]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .next() + .ok_or_else(|| { + HugrError::BuilderError("QAlloc produced no output".to_string()) + })?; + qubit_wires.insert(qubit_ref, qalloc_wire); + } + } + + // Track classical wires from mid-circuit measurements + let mut classical_wires: BTreeMap = BTreeMap::new(); + + // Apply operations + for gate_op in &self.operations { + self.apply_gate( + &mut builder, + &mut qubit_wires, + &mut classical_wires, + gate_op, + )?; + } + + // Measure and free all qubits using MeasureFree, collect bool results + let output_wires: Vec = (0..self.total_qubits) + .map(|global_idx| { + // Find which allocator this belongs to + for (name, alloc) in &self.allocators { + if global_idx >= alloc.start_index + && global_idx < alloc.start_index + alloc.capacity + { + let local_idx = global_idx - alloc.start_index; + let qubit_ref = QubitRef::new(name.clone(), local_idx); + if let Some(&wire) = qubit_wires.get(&qubit_ref) { + // MeasureFree consumes qubit and produces bool + let measure_result = builder + .add_dataflow_op(TketOp::MeasureFree, vec![wire]) + .map_err(|e| HugrError::BuilderError(e.to_string())) + .ok()? + .outputs() + .next()?; + return Some(measure_result); + } + } + } + None + }) + .collect::>>() + .ok_or_else(|| HugrError::BuilderError("Failed to measure all qubits".to_string()))?; + + // Finish HUGR + builder + .finish_hugr_with_outputs(output_wires) + .map_err(|e| HugrError::BuilderError(e.to_string())) + } + + fn build_empty_hugr(&self) -> HugrResult { + let signature = Signature::new(vec![], vec![]); + let builder = + DFGBuilder::new(signature).map_err(|e| HugrError::BuilderError(e.to_string()))?; + builder + .finish_hugr_with_outputs(vec![]) + .map_err(|e| HugrError::BuilderError(e.to_string())) + } + + fn apply_gate( + &self, + builder: &mut DFGBuilder, + qubit_wires: &mut BTreeMap, + classical_wires: &mut BTreeMap, + gate_op: &GateOp, + ) -> HugrResult<()> { + match gate_op { + GateOp::Direct { op, qubits, angle } => { + self.apply_direct_gate(builder, qubit_wires, *op, qubits, *angle)?; + } + + GateOp::Swap { qubit_a, qubit_b } => { + // SWAP decomposition: CX(a,b) CX(b,a) CX(a,b) + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_a.clone(), qubit_b.clone()], + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_b.clone(), qubit_a.clone()], + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_a.clone(), qubit_b.clone()], + None, + )?; + } + + GateOp::ISwap { qubit_a, qubit_b } => { + // iSWAP decomposition: S(a) S(b) H(a) CX(a,b) CX(b,a) H(b) + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::S, + std::slice::from_ref(qubit_a), + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::S, + std::slice::from_ref(qubit_b), + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::H, + std::slice::from_ref(qubit_a), + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_a.clone(), qubit_b.clone()], + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_b.clone(), qubit_a.clone()], + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::H, + std::slice::from_ref(qubit_b), + None, + )?; + } + + GateOp::MidMeasure { qubit, result_var } => { + // Mid-circuit measurement: Measure keeps the qubit alive + let wire = + qubit_wires + .get(qubit) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", qubit.allocator, qubit.index), + })?; + + // Measure produces (qubit, bool) + let outputs: Vec = builder + .add_dataflow_op(TketOp::Measure, vec![wire]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + + // Update qubit wire (first output) + if let Some(&qubit_wire) = outputs.first() { + qubit_wires.insert(qubit.clone(), qubit_wire); + } + + // Store classical result (second output) + if let Some(&bool_wire) = outputs.get(1) { + classical_wires.insert(result_var.clone(), bool_wire); + } + } + + GateOp::Conditional { + condition_var, + then_ops, + else_ops, + } => { + // Get the classical condition wire + let condition_wire = + classical_wires.get(condition_var).copied().ok_or_else(|| { + HugrError::BuilderError(format!( + "Classical variable '{}' not found for conditional", + condition_var + )) + })?; + + // Collect all qubits used in both branches + let mut used_qubits: Vec = Vec::new(); + self.collect_used_qubits(then_ops, &mut used_qubits); + self.collect_used_qubits(else_ops, &mut used_qubits); + + // Deduplicate while preserving order + let mut seen = std::collections::BTreeSet::new(); + used_qubits.retain(|q| seen.insert(q.clone())); + + if used_qubits.is_empty() { + // No qubits affected - just skip this conditional + return Ok(()); + } + + // Collect input wires for the conditional + let qubit_inputs: Vec<(tket::hugr::types::Type, Wire)> = used_qubits + .iter() + .map(|q| { + let wire = qubit_wires.get(q).copied().ok_or_else(|| { + HugrError::UndefinedQubit { + name: format!("{}[{}]", q.allocator, q.index), + } + })?; + Ok((qb_t(), wire)) + }) + .collect::>>()?; + + // Output types are the same as input types (all qubits) + let output_types: Vec<_> = used_qubits.iter().map(|_| qb_t()).collect(); + + // Build the conditional + // HUGR bool is Sum where 0=false, 1=true + let mut conditional = builder + .conditional_builder( + ([type_row![], type_row![]], condition_wire), + qubit_inputs, + output_types.into(), + ) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + + // Case 0: false branch (else_ops) + { + let mut case0 = conditional + .case_builder(0) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let input_wires: Vec = case0.input_wires().collect(); + + // Create temporary wire mapping for this branch + let mut branch_qubit_wires: BTreeMap = used_qubits + .iter() + .zip(input_wires.iter()) + .map(|(q, &w)| (q.clone(), w)) + .collect(); + let mut branch_classical_wires = classical_wires.clone(); + + // Apply else operations + for op in else_ops { + self.apply_gate_in_case( + &mut case0, + &mut branch_qubit_wires, + &mut branch_classical_wires, + op, + )?; + } + + // Collect output wires in the same order as used_qubits + let output_wires: Vec = + used_qubits.iter().map(|q| branch_qubit_wires[q]).collect(); + + case0 + .finish_with_outputs(output_wires) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + } + + // Case 1: true branch (then_ops) + { + let mut case1 = conditional + .case_builder(1) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let input_wires: Vec = case1.input_wires().collect(); + + // Create temporary wire mapping for this branch + let mut branch_qubit_wires: BTreeMap = used_qubits + .iter() + .zip(input_wires.iter()) + .map(|(q, &w)| (q.clone(), w)) + .collect(); + let mut branch_classical_wires = classical_wires.clone(); + + // Apply then operations + for op in then_ops { + self.apply_gate_in_case( + &mut case1, + &mut branch_qubit_wires, + &mut branch_classical_wires, + op, + )?; + } + + // Collect output wires in the same order as used_qubits + let output_wires: Vec = + used_qubits.iter().map(|q| branch_qubit_wires[q]).collect(); + + case1 + .finish_with_outputs(output_wires) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + } + + // Finish conditional and update qubit wires + let cond_handle = conditional + .finish_sub_container() + .map_err(|e: BuildError| HugrError::BuilderError(e.to_string()))?; + + // Update qubit wires with conditional outputs + for (i, qubit_ref) in used_qubits.iter().enumerate() { + if let Some(wire) = cond_handle.outputs().nth(i) { + qubit_wires.insert(qubit_ref.clone(), wire); + } + } + } + } + Ok(()) + } + + /// Collect all qubits used in a list of operations. + fn collect_used_qubits(&self, ops: &[GateOp], qubits: &mut Vec) { + for op in ops { + match op { + GateOp::Direct { qubits: qs, .. } => qubits.extend(qs.iter().cloned()), + GateOp::Swap { qubit_a, qubit_b } => { + qubits.push(qubit_a.clone()); + qubits.push(qubit_b.clone()); + } + GateOp::ISwap { qubit_a, qubit_b } => { + qubits.push(qubit_a.clone()); + qubits.push(qubit_b.clone()); + } + GateOp::MidMeasure { qubit, .. } => qubits.push(qubit.clone()), + GateOp::Conditional { + then_ops, else_ops, .. + } => { + self.collect_used_qubits(then_ops, qubits); + self.collect_used_qubits(else_ops, qubits); + } + } + } + } + + /// Apply a gate operation inside a case builder (for conditionals). + fn apply_gate_in_case( + &self, + builder: &mut T, + qubit_wires: &mut BTreeMap, + classical_wires: &mut BTreeMap, + gate_op: &GateOp, + ) -> HugrResult<()> { + match gate_op { + GateOp::Direct { op, qubits, angle } => { + // Collect input wires for this gate + let input_wires: Vec = qubits + .iter() + .map(|q| { + qubit_wires + .get(q) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", q.allocator, q.index), + }) + }) + .collect::>>()?; + + // Handle rotation angle if present + let all_inputs = if let Some(angle_radians) = angle { + let half_turns = angle_radians / std::f64::consts::PI; + use tket::extension::rotation::ConstRotation; + let const_rotation = ConstRotation::new(half_turns) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let rotation_wire = builder.add_load_value(const_rotation); + let mut inputs = input_wires; + inputs.push(rotation_wire); + inputs + } else { + input_wires + }; + + // Add the gate operation + let output_wires: Vec = builder + .add_dataflow_op(*op, all_inputs) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + + // Update wire mappings + for (i, qubit_ref) in qubits.iter().enumerate() { + if let Some(&wire) = output_wires.get(i) { + qubit_wires.insert(qubit_ref.clone(), wire); + } + } + } + + GateOp::Swap { qubit_a, qubit_b } => { + // SWAP decomposition: CX(a,b) CX(b,a) CX(a,b) + for (q1, q2) in [(qubit_a, qubit_b), (qubit_b, qubit_a), (qubit_a, qubit_b)] { + let in_wires: Vec = vec![qubit_wires[q1], qubit_wires[q2]]; + let out_wires: Vec = builder + .add_dataflow_op(TketOp::CX, in_wires) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + qubit_wires.insert(q1.clone(), out_wires[0]); + qubit_wires.insert(q2.clone(), out_wires[1]); + } + } + + GateOp::ISwap { qubit_a, qubit_b } => { + // iSWAP decomposition: S(a) S(b) H(a) CX(a,b) CX(b,a) H(b) + for (op, qs) in [ + (TketOp::S, vec![qubit_a]), + (TketOp::S, vec![qubit_b]), + (TketOp::H, vec![qubit_a]), + ] { + for q in qs { + let in_wire = qubit_wires[q]; + let out_wire = builder + .add_dataflow_op(op, vec![in_wire]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .next() + .unwrap(); + qubit_wires.insert(q.clone(), out_wire); + } + } + // CX gates + for (q1, q2) in [(qubit_a, qubit_b), (qubit_b, qubit_a)] { + let in_wires: Vec = vec![qubit_wires[q1], qubit_wires[q2]]; + let out_wires: Vec = builder + .add_dataflow_op(TketOp::CX, in_wires) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + qubit_wires.insert(q1.clone(), out_wires[0]); + qubit_wires.insert(q2.clone(), out_wires[1]); + } + // Final H(b) + let in_wire = qubit_wires[qubit_b]; + let out_wire = builder + .add_dataflow_op(TketOp::H, vec![in_wire]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .next() + .unwrap(); + qubit_wires.insert(qubit_b.clone(), out_wire); + } + + GateOp::MidMeasure { qubit, result_var } => { + let wire = + qubit_wires + .get(qubit) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", qubit.allocator, qubit.index), + })?; + + let outputs: Vec = builder + .add_dataflow_op(TketOp::Measure, vec![wire]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + + if let Some(&qubit_wire) = outputs.first() { + qubit_wires.insert(qubit.clone(), qubit_wire); + } + if let Some(&bool_wire) = outputs.get(1) { + classical_wires.insert(result_var.clone(), bool_wire); + } + } + + GateOp::Conditional { .. } => { + // Nested conditionals not yet supported in cases + return Err(HugrError::BuilderError( + "Nested conditionals not yet supported".to_string(), + )); + } + } + Ok(()) + } + + /// Apply a direct TketOp gate. + fn apply_direct_gate( + &self, + builder: &mut DFGBuilder, + qubit_wires: &mut BTreeMap, + op: TketOp, + qubits: &[QubitRef], + angle: Option, + ) -> HugrResult<()> { + // Collect input wires for this gate + let input_wires: Vec = qubits + .iter() + .map(|q| { + qubit_wires + .get(q) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", q.allocator, q.index), + }) + }) + .collect::>>()?; + + // For rotation gates, we need to add the angle as a constant + let all_inputs = if let Some(angle_radians) = angle { + // Convert radians to half-turns (HUGR uses half-turns) + let half_turns = angle_radians / std::f64::consts::PI; + + // Create rotation constant and load it + use tket::extension::rotation::ConstRotation; + let const_rotation = ConstRotation::new(half_turns) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let rotation_wire = builder.add_load_value(const_rotation); + + let mut inputs = input_wires; + inputs.push(rotation_wire); + inputs + } else { + input_wires + }; + + // Add the gate operation + let output_wires: Vec = builder + .add_dataflow_op(op, all_inputs) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + + // Update wire mappings + for (i, qubit_ref) in qubits.iter().enumerate() { + if let Some(&wire) = output_wires.get(i) { + qubit_wires.insert(qubit_ref.clone(), wire); + } + } + + Ok(()) + } + + /// Serialize a HUGR to bytes (text envelope format). + /// + /// This format can be consumed by PECOS's hugr_engine() and sim(). + pub fn to_bytes(&self, hugr: &Hugr) -> HugrResult> { + let mut buffer = Cursor::new(Vec::new()); + hugr.store(&mut buffer, EnvelopeConfig::text()) + .map_err(|e| HugrError::SerializationError(e.to_string()))?; + Ok(buffer.into_inner()) + } + + /// Serialize a HUGR to a string (text envelope format). + /// + /// This format can be consumed by PECOS's hugr_engine() and sim(). + pub fn to_string(&self, hugr: &Hugr) -> HugrResult { + let bytes = self.to_bytes(hugr)?; + String::from_utf8(bytes).map_err(|e| HugrError::SerializationError(e.to_string())) + } +} + +impl Default for HugrCodegen { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + use tket::hugr::HugrView; + + fn compile_to_hugr(source: &str) -> HugrResult { + let program = parse(source).expect("parse failed"); + let mut codegen = HugrCodegen::new(); + codegen.compile(&program) + } + + #[test] + fn test_empty_program() { + let hugr = compile_to_hugr("").unwrap(); + assert!(hugr.num_nodes() > 0); // At least root node + } + + #[test] + fn test_single_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have input, h gate, output nodes + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_bell_state() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have input, h, cx, output nodes + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_rotation_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + rz(1.57, q[0]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_allocator_tracking() { + let source = r#" + pub fn main() -> unit { + mut base := qalloc(4); + mut q := base.child(2); + h q[0]; + h q[1]; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_ccx_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + ccx (q[0], q[1], q[2]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_wrong_argument_count() { + // h is a single-qubit gate, so using batch syntax with two qubits should work fine + // This test was originally testing the old call syntax which is no longer valid + // Let's test that CX with wrong number of qubits fails + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + cx (q[0], q[0]); // CX needs 2 different qubits + } + "#; + + // A repeated qubit (`cx q[0], q[0]`) is a logic issue, not a syntax/arity + // error (arity is checked at the gate expression level, not here), so + // either an Ok or an Err result is acceptable: this only requires that + // codegen does not panic on such input. + let _ = compile_to_hugr(source); + } + + #[test] + fn test_qubit_index_out_of_bounds() { + // Qubit bounds checking is done at semantic analysis, not HUGR codegen + // Use the semantic analyzer directly to verify bounds checking + use crate::semantic::SemanticAnalyzer; + + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[5]; + } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + + assert!(result.is_err(), "Expected QubitIndexOutOfBounds error"); + assert!( + matches!( + result, + Err(crate::semantic::SemanticError::QubitIndexOutOfBounds { + index: 5, + capacity: 2, + .. + }) + ), + "Expected QubitIndexOutOfBounds error, got: {:?}", + result + ); + } + + #[test] + fn test_swap_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + x(q[0]); + swap(q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // swap decomposes to 3 cx gates, so should have more nodes + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_iswap_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + iswap(q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // iswap decomposes to s, s, h, cx, cx, h + assert!(hugr.num_nodes() >= 6); + } + + #[test] + fn test_mid_circuit_measure() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + mz(u1) q[0]; + cx (q[0], q[1]); + } + "#; + + // Mid-circuit measurement should compile without error + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_sx_and_sxdg_gates() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + sx(q[0]); + sxdg(q[0]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_sy_and_sydg_gates() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + sy(q[0]); + sydg(q[0]); + } + "#; + + // SY and SYdg decompose to Ry gates + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_controlled_rotation() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + crz(1.57, q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_reset_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + x(q[0]); + reset(q[0]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_classical_conditional() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + mut result := mz(u1) q[0]; + if (result) { + x(q[1]); + } + } + "#; + + // Classical conditional should compile without error + let hugr = compile_to_hugr(source).unwrap(); + // Should have conditional node in addition to gates + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_classical_conditional_with_else() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + mut result := mz(u1) q[0]; + if (result) { + x(q[1]); + } else { + z(q[1]); + } + } + "#; + + // Classical conditional with else should compile without error + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_ch_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + ch (q[0], q[1]); + } + "#; + + // CH decomposes to Ry CZ Ry + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_szz_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + szz(q[0], q[1]); + } + "#; + + // SZZ decomposes to CX S CX + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_sxx_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + sxx(q[0], q[1]); + } + "#; + + // SXX decomposes to H H (SZZ) H H + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 6); + } + + #[test] + fn test_syy_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + syy(q[0], q[1]); + } + "#; + + // SYY decomposes to Vdg Vdg (SZZ) V V + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 6); + } + + #[test] + fn test_rzz_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + rzz(1.57, q[0], q[1]); + } + "#; + + // RZZ decomposes to CX Rz CX + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_f_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + f(q[0]); + } + "#; + + // F decomposes to H Sdg H Sdg + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_fdg_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + fdg(q[0]); + } + "#; + + // Fdg decomposes to S H S H + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_f4_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + f4(q[0]); + } + "#; + + // F4 decomposes to Ry Rz + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_ising_dagger_gates() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + sxxdg(q[0], q[1]); + syydg(q[0], q[1]); + szzdg(q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 10); + } + + // ========================================================================= + // Batch Operations Tests + // ========================================================================= + + #[test] + fn test_batch_single_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + h {q[0], q[1], q[2]}; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 3 H gates + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_batch_two_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + cx {(q[0], q[1]), (q[2], q[3])}; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 2 CX gates + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_batch_rotation_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + rz(1/8 turns) {q[0], q[1]}; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 2 Rz gates + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_batch_array_syntax() { + // Test &[...] syntax for batch gates + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + h(&[q[0], q[1], q[2]]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 3 H gates + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_batch_array_two_qubit() { + // Test &[(a,b), (c,d)] syntax for batch two-qubit gates + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + cx(&[(q[0], q[1]), (q[2], q[3])]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 2 CX gates + assert!(hugr.num_nodes() >= 3); + } + + // ========================================================================= + // Tick Block Tests + // ========================================================================= + + #[test] + fn test_tick_block() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick { + h q[0]; + h q[1]; + } + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have 2 H gates (tick block is flattened) + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_nested_tick_blocks() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick outer { + tick layer1 { + h {q[0], q[1]}; + } + tick layer2 { + cx {(q[0], q[2]), (q[1], q[3])}; + } + } + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have 2 H + 2 CX gates + assert!(hugr.num_nodes() >= 5); + } + + // ========================================================================= + // Typed Measurement Tests + // ========================================================================= + + #[test] + fn test_typed_measurement_single() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + r := mz(u1) q[0]; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_typed_measurement_array() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + h {q[0], q[1], q[2]}; + results := mz([3]u1) [q[0], q[1], q[2]]; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have 3 H gates + 3 measurements + assert!(hugr.num_nodes() >= 7); + } +} diff --git a/exp/zlup/src/codegen/phir.rs b/exp/zlup/src/codegen/phir.rs new file mode 100644 index 000000000..35af45c7f --- /dev/null +++ b/exp/zlup/src/codegen/phir.rs @@ -0,0 +1,1502 @@ +//! PHIR/JSON code generation for Zlup. +//! +//! This module generates **PHIR/JSON** (the JSON serialization of PHIR) from Zlup AST. +//! +//! ## PHIR vs PHIR/JSON +//! +//! - **PHIR** (PECOS High-level Intermediate Representation): The abstract IR for +//! representing hybrid quantum-classical programs. Defined in the `pecos-phir` crate. +//! - **PHIR/JSON**: The JSON serialization format for PHIR programs, as specified in +//! the `pecos-phir-json` crate (v0.1.0). This is what this module generates. +//! +//! ## Design Philosophy +//! +//! PHIR/JSON provides: +//! - Explicit variable definitions (quantum and classical) +//! - Quantum operations with qubit references +//! - Classical operations with AST-style expressions +//! - Control flow via if/else blocks +//! - Parallel execution via qparallel blocks +//! +//! ## Output Format +//! +//! The output conforms to PHIR/JSON specification v0.1.0: +//! +//! ```json +//! { +//! "format": "PHIR/JSON", +//! "version": "0.1.0", +//! "metadata": {"program_name": "main"}, +//! "ops": [ +//! {"data": "qvar_define", "variable": "q", "size": 2}, +//! {"qop": "H", "args": [["q", 0]]}, +//! {"qop": "CX", "args": [[["q", 0], ["q", 1]]]} +//! ] +//! } +//! ``` + +use std::collections::BTreeMap; +use thiserror::Error; + +use crate::ast::{ + BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, ForRange, GateKind, GateOp, + IfStmt, IntLit, MeasureOp, PrepareOp, Program, Stmt, TickStmt, TopLevelDecl, UnaryOp, +}; + +// ============================================================================= +// Errors +// ============================================================================= + +/// PHIR/JSON code generation errors. +#[derive(Debug, Error)] +pub enum PhirJsonError { + #[error("unknown gate '{name}'")] + UnknownGate { name: String }, + + #[error("undefined allocator '{name}'")] + UndefinedAllocator { name: String }, + + #[error( + "qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}" + )] + QubitIndexOutOfBounds { + allocator: String, + index: usize, + capacity: usize, + }, + + #[error("expected {expected} arguments for gate '{gate}', got {got}")] + WrongArgumentCount { + gate: String, + expected: usize, + got: usize, + }, + + #[error("unsupported expression in PHIR codegen")] + UnsupportedExpression, + + #[error("invalid rotation angle")] + InvalidAngle, + + #[error("JSON serialization error: {0}")] + JsonError(String), + + #[error("unsupported statement in PHIR codegen: {0}")] + UnsupportedStatement(String), +} + +/// Result type for PHIR/JSON code generation. +pub type PhirJsonResult = Result; + +// ============================================================================= +// PHIR/JSON Node Types +// ============================================================================= + +/// Top-level PHIR/JSON program structure. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonProgram { + pub format: &'static str, + pub version: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + pub ops: Vec, +} + +impl Default for PhirJsonProgram { + fn default() -> Self { + Self::new() + } +} + +impl PhirJsonProgram { + pub fn new() -> Self { + Self { + format: "PHIR/JSON", + version: "0.1.0", + metadata: None, + ops: Vec::new(), + } + } + + pub fn with_name(mut self, name: impl Into) -> Self { + self.metadata = Some(PhirJsonMetadata { + program_name: Some(name.into()), + description: None, + strict_parallelism: None, + }); + self + } +} + +/// PHIR/JSON metadata. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub program_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub strict_parallelism: Option, +} + +/// PHIR/JSON operation - can be data, qop, cop, mop, or block. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum PhirJsonOp { + Comment(PhirJsonComment), + QvarDefine(PhirJsonQvarDefine), + CvarDefine(PhirJsonCvarDefine), + CvarExport(PhirJsonCvarExport), + Qop(PhirJsonQop), + Cop(PhirJsonCop), + Block(PhirJsonBlock), + Barrier(PhirJsonBarrier), +} + +/// PHIR/JSON comment. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonComment { + #[serde(rename = "//")] + pub comment: String, +} + +/// PHIR/JSON quantum variable definition. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonQvarDefine { + pub data: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_type: Option<&'static str>, + pub variable: String, + pub size: usize, +} + +impl PhirJsonQvarDefine { + pub fn new(variable: impl Into, size: usize) -> Self { + Self { + data: "qvar_define", + data_type: Some("qubits"), + variable: variable.into(), + size, + } + } +} + +/// PHIR/JSON classical variable definition. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonCvarDefine { + pub data: &'static str, + pub data_type: String, + pub variable: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +impl PhirJsonCvarDefine { + pub fn new(variable: impl Into, size: usize) -> Self { + Self { + data: "cvar_define", + data_type: "i64".to_string(), + variable: variable.into(), + size: Some(size), + } + } +} + +/// PHIR/JSON classical variable export. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonCvarExport { + pub data: &'static str, + pub variables: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option>, +} + +impl PhirJsonCvarExport { + pub fn new(variables: Vec) -> Self { + Self { + data: "cvar_export", + variables, + to: None, + } + } +} + +/// PHIR/JSON quantum operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonQop { + pub qop: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub angles: Option<(Vec, String)>, + pub args: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub returns: Option, +} + +impl PhirJsonQop { + /// Create a single-qubit gate operation. + pub fn single_qubit(gate: impl Into, qubits: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + qop: gate.into(), + angles: None, + args: serde_json::Value::Array(args), + returns: None, + } + } + + /// Create a two-qubit gate operation. + pub fn two_qubit( + gate: impl Into, + pairs: Vec<((String, usize), (String, usize))>, + ) -> Self { + let args: Vec = pairs + .into_iter() + .map(|((n1, i1), (n2, i2))| serde_json::json!([[n1, i1], [n2, i2]])) + .collect(); + Self { + qop: gate.into(), + angles: None, + args: serde_json::Value::Array(args), + returns: None, + } + } + + /// Create a single-qubit rotation. + pub fn rotation( + gate: impl Into, + angle: f64, + unit: &str, + qubits: Vec<(String, usize)>, + ) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + qop: gate.into(), + angles: Some((vec![angle], unit.to_string())), + args: serde_json::Value::Array(args), + returns: None, + } + } + + /// Create a measurement operation. + pub fn measure(qubits: Vec<(String, usize)>, results: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + let rets: Vec = results + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + qop: "Measure".to_string(), + angles: None, + args: serde_json::Value::Array(args), + returns: Some(serde_json::Value::Array(rets)), + } + } + + /// Create an Init operation. + pub fn init(qubits: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + qop: "Init".to_string(), + angles: None, + args: serde_json::Value::Array(args), + returns: None, + } + } +} + +/// PHIR/JSON classical operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonCop { + pub cop: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub returns: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, +} + +impl PhirJsonCop { + /// Create an assignment operation. + pub fn assign(value: serde_json::Value, target: serde_json::Value) -> Self { + Self { + cop: "=".to_string(), + args: Some(serde_json::Value::Array(vec![value])), + returns: Some(serde_json::Value::Array(vec![target])), + function: None, + } + } + + /// Create a Result export operation. + pub fn result(sources: Vec, targets: Vec) -> Self { + Self { + cop: "Result".to_string(), + args: Some(serde_json::Value::Array( + sources.into_iter().map(serde_json::Value::String).collect(), + )), + returns: Some(serde_json::Value::Array( + targets.into_iter().map(serde_json::Value::String).collect(), + )), + function: None, + } + } +} + +/// PHIR/JSON block (sequence, qparallel, if). +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonBlock { + pub block: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ops: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub condition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub true_branch: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub false_branch: Option>, +} + +impl PhirJsonBlock { + /// Create a qparallel block. + pub fn qparallel(ops: Vec) -> Self { + Self { + block: "qparallel".to_string(), + ops: Some(ops), + condition: None, + true_branch: None, + false_branch: None, + } + } + + /// Create an if block. + pub fn if_block( + condition: serde_json::Value, + true_branch: Vec, + false_branch: Option>, + ) -> Self { + Self { + block: "if".to_string(), + ops: None, + condition: Some(condition), + true_branch: Some(true_branch), + false_branch, + } + } +} + +/// PHIR/JSON barrier meta instruction. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonBarrier { + pub meta: &'static str, + pub args: serde_json::Value, +} + +impl PhirJsonBarrier { + pub fn new(qubits: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + meta: "barrier", + args: serde_json::Value::Array(args), + } + } +} + +// ============================================================================= +// Gate Information +// ============================================================================= + +/// Gate information for PHIR/JSON output. +struct GateInfo { + /// Gate name in PHIR/JSON. + phir_name: &'static str, + /// Number of qubits (1 or 2). + num_qubits: usize, + /// Number of angle parameters. + num_angles: usize, +} + +/// Get gate info from GateKind. +fn get_gate_info(kind: GateKind) -> GateInfo { + match kind { + // Single-qubit gates + GateKind::H => GateInfo { + phir_name: "H", + num_qubits: 1, + num_angles: 0, + }, + GateKind::X => GateInfo { + phir_name: "X", + num_qubits: 1, + num_angles: 0, + }, + GateKind::Y => GateInfo { + phir_name: "Y", + num_qubits: 1, + num_angles: 0, + }, + GateKind::Z => GateInfo { + phir_name: "Z", + num_qubits: 1, + num_angles: 0, + }, + GateKind::T => GateInfo { + phir_name: "T", + num_qubits: 1, + num_angles: 0, + }, + GateKind::Tdg => GateInfo { + phir_name: "Tdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SX => GateInfo { + phir_name: "SX", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SXdg => GateInfo { + phir_name: "SXdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SY => GateInfo { + phir_name: "SY", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SYdg => GateInfo { + phir_name: "SYdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SZ => GateInfo { + phir_name: "SZ", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SZdg => GateInfo { + phir_name: "SZdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::F => GateInfo { + phir_name: "F", + num_qubits: 1, + num_angles: 0, + }, + GateKind::Fdg => GateInfo { + phir_name: "Fdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::F4 => GateInfo { + phir_name: "F4", + num_qubits: 1, + num_angles: 0, + }, + GateKind::F4dg => GateInfo { + phir_name: "F4dg", + num_qubits: 1, + num_angles: 0, + }, + + // Single-qubit rotations + GateKind::RX => GateInfo { + phir_name: "RX", + num_qubits: 1, + num_angles: 1, + }, + GateKind::RY => GateInfo { + phir_name: "RY", + num_qubits: 1, + num_angles: 1, + }, + GateKind::RZ => GateInfo { + phir_name: "RZ", + num_qubits: 1, + num_angles: 1, + }, + + // Two-qubit gates + GateKind::CX => GateInfo { + phir_name: "CX", + num_qubits: 2, + num_angles: 0, + }, + GateKind::CY => GateInfo { + phir_name: "CY", + num_qubits: 2, + num_angles: 0, + }, + GateKind::CZ => GateInfo { + phir_name: "CZ", + num_qubits: 2, + num_angles: 0, + }, + GateKind::CH => GateInfo { + phir_name: "CH", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SWAP => GateInfo { + phir_name: "SWAP", + num_qubits: 2, + num_angles: 0, + }, + GateKind::ISWAP => GateInfo { + phir_name: "ISWAP", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SXX => GateInfo { + phir_name: "SXX", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SXXdg => GateInfo { + phir_name: "SXXdg", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SYY => GateInfo { + phir_name: "SYY", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SYYdg => GateInfo { + phir_name: "SYYdg", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SZZ => GateInfo { + phir_name: "SZZ", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SZZdg => GateInfo { + phir_name: "SZZdg", + num_qubits: 2, + num_angles: 0, + }, + GateKind::RZZ => GateInfo { + phir_name: "RZZ", + num_qubits: 2, + num_angles: 1, + }, + + // Three-qubit gates + GateKind::CCX => GateInfo { + phir_name: "CCX", + num_qubits: 3, + num_angles: 0, + }, + + // Prepare operations (treated as Init) + GateKind::PZ => GateInfo { + phir_name: "Init", + num_qubits: 1, + num_angles: 0, + }, + } +} + +// ============================================================================= +// Allocator Tracking +// ============================================================================= + +/// Tracks an allocator during codegen. +#[derive(Debug, Clone)] +struct AllocatorInfo { + name: String, + capacity: usize, +} + +/// Tracks a classical register during codegen. +#[derive(Debug, Clone)] +struct RegisterInfo { + name: String, + size: usize, +} + +// ============================================================================= +// PHIR/JSON Code Generator +// ============================================================================= + +/// PHIR/JSON code generator. +/// +/// Walks a Zlup AST and produces PHIR/JSON output. +pub struct PhirJsonCodegen { + /// Allocators by name. + allocators: BTreeMap, + /// Classical registers by name. + registers: BTreeMap, + /// Auto-generated register counter. + register_counter: usize, +} + +impl Default for PhirJsonCodegen { + fn default() -> Self { + Self::new() + } +} + +impl PhirJsonCodegen { + /// Create a new PHIR/JSON code generator. + pub fn new() -> Self { + Self { + allocators: BTreeMap::new(), + registers: BTreeMap::new(), + register_counter: 0, + } + } + + /// Compile a Zlup program to PHIR/JSON. + pub fn compile(&mut self, program: &Program) -> PhirJsonResult { + let mut phir = PhirJsonProgram::new().with_name("main"); + + // First pass: collect allocators + for decl in &program.declarations { + self.collect_decl(decl)?; + } + + // Add quantum variable definitions + for alloc in self.allocators.values() { + phir.ops + .push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( + &alloc.name, + alloc.capacity, + ))); + } + + // Add classical variable definitions + for reg in self.registers.values() { + phir.ops + .push(PhirJsonOp::CvarDefine(PhirJsonCvarDefine::new( + ®.name, reg.size, + ))); + } + + // Second pass: convert main function body + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl + && fn_decl.name == "main" + { + let ops = self.convert_block(&fn_decl.body)?; + phir.ops.extend(ops); + } + } + + // Export all classical variables + if !self.registers.is_empty() { + let vars: Vec = self.registers.keys().cloned().collect(); + phir.ops + .push(PhirJsonOp::CvarExport(PhirJsonCvarExport::new(vars))); + } + + Ok(phir) + } + + /// Compile a function to PHIR/JSON. + pub fn compile_function(&mut self, fn_decl: &FnDecl) -> PhirJsonResult { + // Collect from function body + self.collect_block(&fn_decl.body)?; + + let mut phir = PhirJsonProgram::new().with_name(&fn_decl.name); + + // Add definitions + for alloc in self.allocators.values() { + phir.ops + .push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( + &alloc.name, + alloc.capacity, + ))); + } + + for reg in self.registers.values() { + phir.ops + .push(PhirJsonOp::CvarDefine(PhirJsonCvarDefine::new( + ®.name, reg.size, + ))); + } + + // Convert body + let ops = self.convert_block(&fn_decl.body)?; + phir.ops.extend(ops); + + Ok(phir) + } + + /// Convert to JSON string. + pub fn to_json(&self, program: &PhirJsonProgram) -> PhirJsonResult { + serde_json::to_string_pretty(program).map_err(|e| PhirJsonError::JsonError(e.to_string())) + } + + /// Convert to compact JSON string. + pub fn to_json_compact(&self, program: &PhirJsonProgram) -> PhirJsonResult { + serde_json::to_string(program).map_err(|e| PhirJsonError::JsonError(e.to_string())) + } + + // ========================================================================= + // Collection Phase + // ========================================================================= + + fn collect_decl(&mut self, decl: &TopLevelDecl) -> PhirJsonResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) if fn_decl.name == "main" => { + self.collect_block(&fn_decl.body)?; + } + TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, + _ => {} + } + Ok(()) + } + + fn collect_block(&mut self, block: &Block) -> PhirJsonResult<()> { + for stmt in &block.statements { + self.collect_stmt(stmt)?; + } + Ok(()) + } + + fn collect_stmt(&mut self, stmt: &Stmt) -> PhirJsonResult<()> { + match stmt { + Stmt::Binding(binding) => self.collect_binding(binding)?, + Stmt::If(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + ElseBranch::Else(block) => self.collect_block(block)?, + ElseBranch::ElseIf(nested_if) => { + self.collect_stmt(&Stmt::If(*nested_if.clone()))?; + } + } + } + } + Stmt::For(for_stmt) => { + self.collect_block(&for_stmt.body)?; + } + Stmt::Tick(tick_stmt) => { + for stmt in &tick_stmt.body { + self.collect_stmt(stmt)?; + } + } + Stmt::Block(block) => { + self.collect_block(block)?; + } + _ => {} + } + Ok(()) + } + + fn collect_binding(&mut self, binding: &Binding) -> PhirJsonResult<()> { + if let Some(ref init) = binding.value { + // Check for qalloc + if let Expr::Call(call) = init + && self.get_callee_name(call) == Some("qalloc".to_string()) + && let Some(Expr::IntLit(IntLit { value, .. })) = call.args.first() + { + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity: *value as usize, + }, + ); + } + + // Check for typed measurement (creates a register) + if let Expr::Call(call) = init + && let Some(name) = self.get_callee_name(call) + && (name == "mz" || name == "mx" || name == "my") + { + let size = call.args.len().max(1); + self.registers.insert( + binding.name.clone(), + RegisterInfo { + name: binding.name.clone(), + size, + }, + ); + } + } + Ok(()) + } + + // ========================================================================= + // Conversion Phase + // ========================================================================= + + fn convert_block(&mut self, block: &Block) -> PhirJsonResult> { + let mut ops = Vec::new(); + for stmt in &block.statements { + ops.extend(self.convert_stmt(stmt)?); + } + Ok(ops) + } + + fn convert_stmt(&mut self, stmt: &Stmt) -> PhirJsonResult> { + match stmt { + Stmt::Binding(binding) => self.convert_binding(binding), + Stmt::Expr(expr_stmt) => self.convert_expr_stmt(&expr_stmt.expr), + Stmt::If(if_stmt) => self.convert_if(if_stmt), + Stmt::For(for_stmt) => self.convert_for(for_stmt), + Stmt::Tick(tick_stmt) => self.convert_tick(tick_stmt), + Stmt::Return(_) => Ok(vec![]), + Stmt::Block(block) => self.convert_block(block), + Stmt::Gate(gate_op) => self.convert_gate(gate_op), + Stmt::Prepare(prepare_op) => self.convert_prepare(prepare_op), + Stmt::Measure(measure_op) => self.convert_measure(measure_op), + Stmt::Barrier(barrier_op) => { + let qubits: Vec<(String, usize)> = barrier_op + .allocators + .iter() + .flat_map(|alloc| { + self.allocators + .get(alloc) + .map(|info| { + (0..info.capacity) + .map(|i| (info.name.clone(), i)) + .collect::>() + }) + .unwrap_or_default() + }) + .collect(); + Ok(vec![PhirJsonOp::Barrier(PhirJsonBarrier::new(qubits))]) + } + Stmt::Break(_) | Stmt::Continue(_) => Ok(vec![]), + _ => Ok(vec![]), + } + } + + fn convert_binding(&mut self, binding: &Binding) -> PhirJsonResult> { + let mut ops = Vec::new(); + + if let Some(ref init) = binding.value { + // Check for qalloc - already handled in collection phase + if let Expr::Call(call) = init + && self.get_callee_name(call) == Some("qalloc".to_string()) + { + return Ok(vec![]); + } + + // Check for measurement call (mz(...) [targets]) + if let Expr::Call(call) = init + && let Some(name) = self.get_callee_name(call) + && (name == "mz" || name == "mx" || name == "my") + { + let qubits = self.extract_qubits_from_args(&call.args)?; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (binding.name.clone(), i)) + .collect(); + ops.push(PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))); + return Ok(ops); + } + + // Check for measurement expression (mz(T) targets) + if let Expr::Measure(measure_expr) = init { + let qubits = self.extract_qubits_from_target(&measure_expr.targets)?; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (binding.name.clone(), i)) + .collect(); + ops.push(PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))); + return Ok(ops); + } + + // Try to convert to a value for assignment - skip unsupported expressions + match self.convert_expr_to_value(init) { + Ok(value) => { + ops.push(PhirJsonOp::Cop(PhirJsonCop::assign( + value, + serde_json::Value::String(binding.name.clone()), + ))); + } + Err(PhirJsonError::UnsupportedExpression) => { + // Skip unsupported expressions silently - they may be quantum ops + } + Err(e) => return Err(e), + } + } + + Ok(ops) + } + + fn convert_expr_stmt(&mut self, expr: &Expr) -> PhirJsonResult> { + match expr { + Expr::Call(call) => self.convert_call(call), + Expr::Gate(gate_expr) => self.convert_gate_expr(gate_expr), + Expr::Measure(measure_expr) => self.convert_measure_expr(measure_expr), + _ => Ok(vec![]), + } + } + + fn convert_gate_expr( + &self, + gate_expr: &crate::ast::GateExpr, + ) -> PhirJsonResult> { + let gate_info = get_gate_info(gate_expr.kind); + + // Handle prepare operations + if gate_info.phir_name == "Init" { + let qubits = self.extract_qubits_from_target(&gate_expr.target)?; + if qubits.is_empty() { + // Prepare all qubits in the allocator + if let Expr::Ident(ident) = &gate_expr.target + && let Some(alloc) = self.allocators.get(&ident.name) + { + let all_qubits: Vec<(String, usize)> = (0..alloc.capacity) + .map(|i| (alloc.name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(all_qubits))]); + } + } + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]); + } + + let qubits = self.extract_qubits_from_target(&gate_expr.target)?; + + if gate_info.num_qubits == 1 { + if gate_info.num_angles > 0 { + // Rotation gate - get angle from params + let angle = gate_expr + .params + .first() + .and_then(|p| self.eval_expr_to_float(p)) + .unwrap_or(0.0); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::rotation( + gate_info.phir_name, + angle * std::f64::consts::TAU, + "rad", + qubits, + ))]) + } else { + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::single_qubit( + gate_info.phir_name, + qubits, + ))]) + } + } else { + // Two-qubit gate - pair up qubits + if qubits.len() % 2 != 0 && gate_info.num_qubits == 2 { + return Err(PhirJsonError::WrongArgumentCount { + gate: gate_info.phir_name.to_string(), + expected: 2, + got: qubits.len(), + }); + } + let pairs: Vec<_> = qubits + .chunks(2) + .map(|chunk| (chunk[0].clone(), chunk[1].clone())) + .collect(); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::two_qubit( + gate_info.phir_name, + pairs, + ))]) + } + } + + fn convert_measure_expr( + &mut self, + measure_expr: &crate::ast::MeasureExpr, + ) -> PhirJsonResult> { + let qubits = self.extract_qubits_from_target(&measure_expr.targets)?; + let reg_name = format!("m{}", self.register_counter); + self.register_counter += 1; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (reg_name.clone(), i)) + .collect(); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))]) + } + + fn extract_qubits_from_target(&self, target: &Expr) -> PhirJsonResult> { + let mut qubits = Vec::new(); + match target { + Expr::Index(idx_expr) => { + if let Expr::Ident(ident) = &idx_expr.object + && let Some(idx) = self.eval_index(&idx_expr.index) + { + qubits.push((ident.name.clone(), idx)); + } + } + Expr::Tuple(tuple_expr) => { + for elem in &tuple_expr.elements { + qubits.extend(self.extract_qubits_from_target(elem)?); + } + } + Expr::BracketArray(arr) => { + for elem in &arr.elements { + qubits.extend(self.extract_qubits_from_target(elem)?); + } + } + Expr::Set(set_expr) => { + for elem in &set_expr.elements { + qubits.extend(self.extract_qubits_from_target(elem)?); + } + } + Expr::SlotRef(slot_ref) => { + if let Some(idx) = self.eval_index(&slot_ref.index) { + qubits.push((slot_ref.allocator.clone(), idx)); + } + } + Expr::Ident(_) => { + // This is a bare allocator name - handled by caller + } + _ => {} + } + Ok(qubits) + } + + fn convert_call(&mut self, call: &CallExpr) -> PhirJsonResult> { + let name = self.get_callee_name(call).unwrap_or_default(); + + // Check for prepare operations + if name == "pz" || name == "px" || name == "py" { + let qubits = self.extract_qubits_from_args(&call.args)?; + if qubits.is_empty() { + // Prepare all qubits in the allocator + if let Some(Expr::Ident(ident)) = call.args.first() + && let Some(alloc) = self.allocators.get(&ident.name) + { + let all_qubits: Vec<(String, usize)> = (0..alloc.capacity) + .map(|i| (alloc.name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(all_qubits))]); + } + } + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]); + } + + // Check for measurement + if name == "mz" || name == "mx" || name == "my" { + let qubits = self.extract_qubits_from_args(&call.args)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (reg_name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))]); + } + + Ok(vec![]) + } + + fn convert_gate(&self, gate_op: &GateOp) -> PhirJsonResult> { + let gate_info = get_gate_info(gate_op.kind); + + // Handle prepare operations + if gate_info.phir_name == "Init" { + let qubits = self.convert_slot_refs(&gate_op.targets); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]); + } + + // Handle measurement operations + if gate_info.phir_name == "Measure" { + let qubits = self.convert_slot_refs(&gate_op.targets); + let reg_name = format!("m{}", qubits.len()); + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (reg_name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))]); + } + + let qubits = self.convert_slot_refs(&gate_op.targets); + + if gate_info.num_qubits == 1 { + if gate_info.num_angles > 0 { + // Rotation gate - get angle from params + let angle = gate_op + .params + .first() + .and_then(|p| self.eval_expr_to_float(p)) + .unwrap_or(0.0); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::rotation( + gate_info.phir_name, + angle * std::f64::consts::TAU, + "rad", + qubits, + ))]) + } else { + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::single_qubit( + gate_info.phir_name, + qubits, + ))]) + } + } else { + // Two-qubit gate - pair up qubits + if !qubits.len().is_multiple_of(2) { + return Err(PhirJsonError::WrongArgumentCount { + gate: gate_info.phir_name.to_string(), + expected: 2, + got: qubits.len(), + }); + } + let pairs: Vec<_> = qubits + .chunks(2) + .map(|chunk| (chunk[0].clone(), chunk[1].clone())) + .collect(); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::two_qubit( + gate_info.phir_name, + pairs, + ))]) + } + } + + fn convert_prepare(&self, prepare_op: &PrepareOp) -> PhirJsonResult> { + let alloc = self.allocators.get(&prepare_op.allocator).ok_or_else(|| { + PhirJsonError::UndefinedAllocator { + name: prepare_op.allocator.clone(), + } + })?; + + let qubits: Vec<(String, usize)> = if let Some(ref slots) = prepare_op.slots { + slots + .iter() + .map(|&i| (alloc.name.clone(), i as usize)) + .collect() + } else { + (0..alloc.capacity) + .map(|i| (alloc.name.clone(), i)) + .collect() + }; + + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]) + } + + fn convert_measure(&mut self, measure_op: &MeasureOp) -> PhirJsonResult> { + let qubits = self.convert_slot_refs(&measure_op.targets); + let results: Vec<(String, usize)> = measure_op + .results + .iter() + .filter_map(|br| { + self.eval_index(&br.index) + .map(|idx| (br.register.clone(), idx)) + }) + .collect(); + + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))]) + } + + fn convert_if(&mut self, if_stmt: &IfStmt) -> PhirJsonResult> { + let condition = self.convert_expr_to_value(&if_stmt.condition)?; + let true_branch = self.convert_block(&if_stmt.then_body)?; + + let false_branch = if let Some(ref else_branch) = if_stmt.else_body { + match else_branch { + ElseBranch::Else(block) => Some(self.convert_block(block)?), + ElseBranch::ElseIf(nested_if) => Some(self.convert_if(nested_if)?), + } + } else { + None + }; + + Ok(vec![PhirJsonOp::Block(PhirJsonBlock::if_block( + condition, + true_branch, + false_branch, + ))]) + } + + fn convert_for(&mut self, for_stmt: &crate::ast::ForStmt) -> PhirJsonResult> { + let mut ops = Vec::new(); + + if let ForRange::Range { start, end, .. } = &for_stmt.range + && let (Some(start_val), Some(end_val)) = + (self.try_eval_const(start), self.try_eval_const(end)) + { + for _ in start_val..end_val { + ops.extend(self.convert_block(&for_stmt.body)?); + } + return Ok(ops); + } + + Err(PhirJsonError::UnsupportedStatement( + "for loops with non-constant bounds".to_string(), + )) + } + + fn convert_tick(&mut self, tick_stmt: &TickStmt) -> PhirJsonResult> { + let mut qops = Vec::new(); + for stmt in &tick_stmt.body { + let converted = self.convert_stmt(stmt)?; + for op in converted { + if matches!(op, PhirJsonOp::Qop(_)) { + qops.push(op); + } + } + } + + if qops.is_empty() { + Ok(vec![]) + } else { + Ok(vec![PhirJsonOp::Block(PhirJsonBlock::qparallel(qops))]) + } + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + fn get_callee_name(&self, call: &CallExpr) -> Option { + match &call.callee { + Expr::Ident(ident) => Some(ident.name.clone()), + _ => None, + } + } + + fn convert_slot_refs(&self, targets: &[crate::ast::SlotRef]) -> Vec<(String, usize)> { + targets + .iter() + .filter_map(|slot| { + self.eval_index(&slot.index) + .map(|idx| (slot.allocator.clone(), idx)) + }) + .collect() + } + + fn extract_qubits_from_args(&self, args: &[Expr]) -> PhirJsonResult> { + let mut qubits = Vec::new(); + for arg in args { + match arg { + Expr::Index(idx_expr) => { + if let Expr::Ident(ident) = &idx_expr.object + && let Some(idx) = self.eval_index(&idx_expr.index) + { + qubits.push((ident.name.clone(), idx)); + } + } + Expr::Tuple(tuple_expr) => { + for elem in &tuple_expr.elements { + if let Expr::Index(idx_expr) = elem + && let Expr::Ident(ident) = &idx_expr.object + && let Some(idx) = self.eval_index(&idx_expr.index) + { + qubits.push((ident.name.clone(), idx)); + } + } + } + Expr::BracketArray(arr) => { + for elem in &arr.elements { + if let Expr::Index(idx_expr) = elem + && let Expr::Ident(ident) = &idx_expr.object + && let Some(idx) = self.eval_index(&idx_expr.index) + { + qubits.push((ident.name.clone(), idx)); + } + } + } + _ => {} + } + } + Ok(qubits) + } + + fn eval_index(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(IntLit { value, .. }) => Some(*value as usize), + _ => None, + } + } + + fn eval_expr_to_float(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(IntLit { value, .. }) => Some(*value as f64), + Expr::FloatLit(fl) => Some(fl.value), + Expr::AngleLit(angle) => { + // Evaluate the angle value and convert to turns + let value = self.eval_expr_to_float(&angle.value)?; + Some(angle.unit.to_turns(value)) + } + _ => None, + } + } + + fn try_eval_const(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(IntLit { value, .. }) => Some(*value as i64), + _ => None, + } + } + + fn convert_expr_to_value(&self, expr: &Expr) -> PhirJsonResult { + match expr { + Expr::IntLit(IntLit { value, .. }) => { + Ok(serde_json::Value::Number((*value as i64).into())) + } + Expr::FloatLit(fl) => Ok(serde_json::json!(fl.value)), + Expr::BoolLit(bl) => Ok(serde_json::Value::Number( + if bl.value { 1 } else { 0 }.into(), + )), + Expr::Ident(ident) => Ok(serde_json::Value::String(ident.name.clone())), + Expr::Binary(bin) => { + let left = self.convert_expr_to_value(&bin.left)?; + let right = self.convert_expr_to_value(&bin.right)?; + let op = match bin.op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Mul => "*", + BinaryOp::Div => "/", + BinaryOp::Mod => "%", + BinaryOp::Eq => "==", + BinaryOp::Ne => "!=", + BinaryOp::Lt => "<", + BinaryOp::Le => "<=", + BinaryOp::Gt => ">", + BinaryOp::Ge => ">=", + BinaryOp::BitAnd => "&", + BinaryOp::BitOr => "|", + BinaryOp::BitXor => "^", + BinaryOp::Shl => "<<", + BinaryOp::Shr => ">>", + BinaryOp::And => "&", + BinaryOp::Or => "|", + _ => return Err(PhirJsonError::UnsupportedExpression), + }; + Ok(serde_json::json!({"cop": op, "args": [left, right]})) + } + Expr::Unary(un) => { + let operand = self.convert_expr_to_value(&un.operand)?; + let op = match un.op { + UnaryOp::Neg => "-", + UnaryOp::Not => "~", + _ => return Err(PhirJsonError::UnsupportedExpression), + }; + Ok(serde_json::json!({"cop": op, "args": [operand]})) + } + Expr::Index(idx) => { + if let Expr::Ident(ident) = &idx.object + && let Some(i) = self.eval_index(&idx.index) + { + return Ok(serde_json::json!([ident.name, i])); + } + Err(PhirJsonError::UnsupportedExpression) + } + _ => Err(PhirJsonError::UnsupportedExpression), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bell_state() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + return unit; +} +"#; + let ast = crate::parse(source).unwrap(); + let mut codegen = PhirJsonCodegen::new(); + let phir = codegen.compile(&ast).unwrap(); + let json = codegen.to_json(&phir).unwrap(); + + assert!(json.contains("\"format\": \"PHIR/JSON\"")); + assert!(json.contains("\"version\": \"0.1.0\"")); + assert!(json.contains("\"qvar_define\"")); + assert!(json.contains("\"H\"")); + assert!(json.contains("\"CX\"")); + assert!(json.contains("\"Measure\"")); + } + + #[test] + fn test_ghz_state() { + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + h q[0]; + cx (q[0], q[1]); + cx (q[0], q[2]); + cx (q[0], q[3]); + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + return unit; +} +"#; + let ast = crate::parse(source).unwrap(); + let mut codegen = PhirJsonCodegen::new(); + let phir = codegen.compile(&ast).unwrap(); + let json = codegen.to_json(&phir).unwrap(); + + assert!(json.contains("\"size\": 4")); + assert!(json.contains("\"CX\"")); + } + + #[test] + fn test_single_qubit_gates() { + let source = r#" +pub fn main() -> unit { + q := qalloc(1); + pz q; + h q[0]; + x q[0]; + y q[0]; + z q[0]; + sz q[0]; + t q[0]; + return unit; +} +"#; + let ast = crate::parse(source).unwrap(); + let mut codegen = PhirJsonCodegen::new(); + let phir = codegen.compile(&ast).unwrap(); + let json = codegen.to_json(&phir).unwrap(); + + assert!(json.contains("\"H\"")); + assert!(json.contains("\"X\"")); + assert!(json.contains("\"Y\"")); + assert!(json.contains("\"Z\"")); + assert!(json.contains("\"SZ\"")); + assert!(json.contains("\"T\"")); + } + + #[test] + fn test_to_json_format() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + return unit; +} +"#; + let ast = crate::parse(source).unwrap(); + let mut codegen = PhirJsonCodegen::new(); + let phir = codegen.compile(&ast).unwrap(); + let json = codegen.to_json(&phir).unwrap(); + + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["format"], "PHIR/JSON"); + assert_eq!(parsed["version"], "0.1.0"); + assert!(parsed["ops"].is_array()); + } +} diff --git a/exp/zlup/src/codegen/qasm.rs b/exp/zlup/src/codegen/qasm.rs new file mode 100644 index 000000000..a4d2e2d1b --- /dev/null +++ b/exp/zlup/src/codegen/qasm.rs @@ -0,0 +1,1148 @@ +//! OpenQASM 2.0 code generation for Zlup. +//! +//! This module generates OpenQASM 2.0 from Zlup AST, enabling execution on +//! simulators and hardware that support the QASM format. +//! +//! ## Output Format +//! +//! ```qasm +//! OPENQASM 2.0; +//! include "qelib1.inc"; +//! +//! qreg q[4]; +//! creg c[4]; +//! +//! h q[0]; +//! cx q[0], q[1]; +//! measure q[0] -> c[0]; +//! ``` + +use std::collections::BTreeMap; +use std::fmt::Write; +use thiserror::Error; + +use crate::ast::{ + BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, Program, Stmt, TopLevelDecl, +}; + +// ============================================================================= +// Errors +// ============================================================================= + +/// QASM code generation errors. +#[derive(Debug, Error)] +pub enum QasmError { + #[error("unknown gate '{name}'")] + UnknownGate { name: String }, + + #[error("undefined allocator '{name}'")] + UndefinedAllocator { name: String }, + + #[error( + "qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}" + )] + QubitIndexOutOfBounds { + allocator: String, + index: usize, + capacity: usize, + }, + + #[error("expected {expected} arguments for gate '{gate}', got {got}")] + WrongArgumentCount { + gate: String, + expected: usize, + got: usize, + }, + + #[error("unsupported expression in QASM codegen")] + UnsupportedExpression, + + #[error("unsupported control flow in QASM 2.0")] + UnsupportedControlFlow, + + #[error("formatting error: {0}")] + FormatError(std::fmt::Error), +} + +impl From for QasmError { + fn from(e: std::fmt::Error) -> Self { + QasmError::FormatError(e) + } +} + +/// Result type for QASM code generation. +pub type QasmResult = Result; + +// ============================================================================= +// Gate Mapping +// ============================================================================= + +/// Gate information for QASM. +struct GateInfo { + /// Gate name in QASM. + name: &'static str, + /// Number of qubit targets. + arity: usize, + /// Whether this gate takes parameters. + parameterized: bool, +} + +/// Maps Zlup gate names to QASM gate info. +fn get_gate_info(name: &str) -> Option { + match name { + // Single-qubit Pauli gates + "x" => Some(GateInfo { + name: "x", + arity: 1, + parameterized: false, + }), + "y" => Some(GateInfo { + name: "y", + arity: 1, + parameterized: false, + }), + "z" => Some(GateInfo { + name: "z", + arity: 1, + parameterized: false, + }), + + // Hadamard + "h" => Some(GateInfo { + name: "h", + arity: 1, + parameterized: false, + }), + + // S gates (zlup uses sz/szdg, QASM uses s/sdg) + "sz" => Some(GateInfo { + name: "s", + arity: 1, + parameterized: false, + }), + "szdg" => Some(GateInfo { + name: "sdg", + arity: 1, + parameterized: false, + }), + + // T gates + "t" => Some(GateInfo { + name: "t", + arity: 1, + parameterized: false, + }), + "tdg" => Some(GateInfo { + name: "tdg", + arity: 1, + parameterized: false, + }), + + // Square root gates + "sx" => Some(GateInfo { + name: "sx", + arity: 1, + parameterized: false, + }), + + // Rotation gates + "rx" => Some(GateInfo { + name: "rx", + arity: 1, + parameterized: true, + }), + "ry" => Some(GateInfo { + name: "ry", + arity: 1, + parameterized: true, + }), + "rz" => Some(GateInfo { + name: "rz", + arity: 1, + parameterized: true, + }), + + // U gates (parameterized) + "u1" => Some(GateInfo { + name: "u1", + arity: 1, + parameterized: true, + }), + "u2" => Some(GateInfo { + name: "u2", + arity: 1, + parameterized: true, + }), + "u3" => Some(GateInfo { + name: "u3", + arity: 1, + parameterized: true, + }), + + // Two-qubit gates + "cx" => Some(GateInfo { + name: "cx", + arity: 2, + parameterized: false, + }), + "cy" => Some(GateInfo { + name: "cy", + arity: 2, + parameterized: false, + }), + "cz" => Some(GateInfo { + name: "cz", + arity: 2, + parameterized: false, + }), + "ch" => Some(GateInfo { + name: "ch", + arity: 2, + parameterized: false, + }), + "swap" => Some(GateInfo { + name: "swap", + arity: 2, + parameterized: false, + }), + + // Two-qubit rotation + "rzz" => Some(GateInfo { + name: "rzz", + arity: 2, + parameterized: true, + }), + + // Three-qubit gates + "ccx" => Some(GateInfo { + name: "ccx", + arity: 3, + parameterized: false, + }), + + _ => None, + } +} + +// ============================================================================= +// Code Generator +// ============================================================================= + +/// Tracks an allocator during codegen. +#[derive(Debug, Clone)] +struct AllocatorInfo { + name: String, + capacity: usize, + /// Global offset for this allocator in the flat qubit register + offset: usize, +} + +/// QASM code generator. +/// +/// Walks a Zlup AST and produces OpenQASM 2.0. +pub struct QasmCodegen { + /// Allocators by name. + allocators: BTreeMap, + /// Total qubit count. + total_qubits: usize, + /// Classical register counter. + creg_counter: usize, + /// Output buffer. + output: String, +} + +impl QasmCodegen { + /// Create a new QASM code generator. + pub fn new() -> Self { + Self { + allocators: BTreeMap::new(), + total_qubits: 0, + creg_counter: 0, + output: String::new(), + } + } + + /// Compile a Zlup program to OpenQASM 2.0. + pub fn compile(&mut self, program: &Program) -> QasmResult { + // Reset state + self.allocators.clear(); + self.total_qubits = 0; + self.creg_counter = 0; + self.output.clear(); + + // First pass: collect allocators + for decl in &program.declarations { + self.collect_decl(decl)?; + } + + // Write header + writeln!(self.output, "OPENQASM 2.0;")?; + writeln!(self.output, "include \"qelib1.inc\";")?; + writeln!(self.output)?; + + // Write qubit register + if self.total_qubits > 0 { + writeln!(self.output, "qreg q[{}];", self.total_qubits)?; + } + + // Second pass: convert statements and count measurements + let mut body_output = String::new(); + let mut measurement_count = 0; + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl + && fn_decl.name == "main" + { + let (body, mcount) = self.convert_block(&fn_decl.body)?; + body_output = body; + measurement_count = mcount; + } + } + + // Write classical register if measurements exist + if measurement_count > 0 { + writeln!(self.output, "creg c[{}];", measurement_count)?; + } + + writeln!(self.output)?; + + // Write body + self.output.push_str(&body_output); + + Ok(self.output.clone()) + } + + /// Compile a function to OpenQASM 2.0. + pub fn compile_function(&mut self, fn_decl: &FnDecl) -> QasmResult { + // Reset state + self.allocators.clear(); + self.total_qubits = 0; + self.creg_counter = 0; + self.output.clear(); + + // Collect from function body + self.collect_block(&fn_decl.body)?; + + // Write header + writeln!(self.output, "OPENQASM 2.0;")?; + writeln!(self.output, "include \"qelib1.inc\";")?; + writeln!(self.output)?; + + // Write qubit register + if self.total_qubits > 0 { + writeln!(self.output, "qreg q[{}];", self.total_qubits)?; + } + + // Convert body + let (body, measurement_count) = self.convert_block(&fn_decl.body)?; + + // Write classical register + if measurement_count > 0 { + writeln!(self.output, "creg c[{}];", measurement_count)?; + } + + writeln!(self.output)?; + self.output.push_str(&body); + + Ok(self.output.clone()) + } + + // ========================================================================= + // Collection Phase + // ========================================================================= + + fn collect_decl(&mut self, decl: &TopLevelDecl) -> QasmResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) if fn_decl.name == "main" => { + self.collect_block(&fn_decl.body)?; + } + TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, + _ => {} + } + Ok(()) + } + + fn collect_binding(&mut self, binding: &Binding) -> QasmResult<()> { + if let Some(ref value) = binding.value { + if let Some(capacity) = self.try_extract_allocator(value) { + let offset = self.total_qubits; + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity, + offset, + }, + ); + self.total_qubits += capacity; + } else if let Some((parent, size)) = self.try_extract_child_allocator(value) { + // Child allocator shares parent's qubits + if let Some(parent_info) = self.allocators.get(&parent) { + let offset = parent_info.offset; + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity: size, + offset, + }, + ); + } + } + } + Ok(()) + } + + fn collect_block(&mut self, block: &Block) -> QasmResult<()> { + for stmt in &block.statements { + self.collect_stmt(stmt)?; + } + Ok(()) + } + + fn collect_stmt(&mut self, stmt: &Stmt) -> QasmResult<()> { + match stmt { + Stmt::Binding(binding) => self.collect_binding(binding)?, + Stmt::If(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + Stmt::For(for_stmt) => self.collect_block(&for_stmt.body)?, + Stmt::Block(block) => self.collect_block(block)?, + Stmt::Tick(tick_stmt) => { + for inner_stmt in &tick_stmt.body { + self.collect_stmt(inner_stmt)?; + } + } + _ => {} + } + Ok(()) + } + + fn collect_else_branch(&mut self, branch: &ElseBranch) -> QasmResult<()> { + match branch { + ElseBranch::Else(block) => self.collect_block(block)?, + ElseBranch::ElseIf(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + } + Ok(()) + } + + // ========================================================================= + // Conversion Phase + // ========================================================================= + + /// Convert a block, returning (output, measurement_count) + fn convert_block(&mut self, block: &Block) -> QasmResult<(String, usize)> { + let mut output = String::new(); + let mut measurement_count = 0; + + for stmt in &block.statements { + let (stmt_output, mcount) = self.convert_stmt(stmt)?; + output.push_str(&stmt_output); + measurement_count += mcount; + } + + Ok((output, measurement_count)) + } + + /// Convert a statement, returning (output, measurement_count) + fn convert_stmt(&mut self, stmt: &Stmt) -> QasmResult<(String, usize)> { + match stmt { + Stmt::Expr(expr_stmt) => self.convert_expr_stmt(expr_stmt), + Stmt::Tick(tick_stmt) => { + // Tick blocks are flattened - operations within are just sequential in QASM + let mut output = String::new(); + let mut measurement_count = 0; + + // Add barrier to mark tick boundary (optional but useful) + if !tick_stmt.body.is_empty() { + writeln!( + output, + "// tick{}", + tick_stmt + .label + .as_ref() + .map(|l| format!(" {}", l)) + .unwrap_or_default() + )?; + } + + for inner_stmt in &tick_stmt.body { + let (stmt_output, mcount) = self.convert_stmt(inner_stmt)?; + output.push_str(&stmt_output); + measurement_count += mcount; + } + + Ok((output, measurement_count)) + } + Stmt::Block(block) => self.convert_block(block), + // Handle declarations - check for measurement calls + Stmt::Binding(binding) => { + if let Some(ref value) = binding.value { + self.convert_decl_value(value) + } else { + Ok((String::new(), 0)) + } + } + // Skip unsupported control flow in QASM 2.0 + // (QASM 3.0 would support these) + Stmt::If(_) | Stmt::For(_) => { + // For now, skip control flow - could emit warning + Ok((String::new(), 0)) + } + _ => Ok((String::new(), 0)), + } + } + + fn convert_decl_value(&mut self, expr: &Expr) -> QasmResult<(String, usize)> { + match expr { + Expr::Call(call) => { + let name = self.extract_call_name(&call.callee)?; + if name == "mz" { + return self.convert_measure(call); + } + Ok((String::new(), 0)) + } + Expr::Measure(measure) => self.convert_measure_expr(measure), + _ => Ok((String::new(), 0)), + } + } + + fn convert_expr_stmt( + &mut self, + expr_stmt: &crate::ast::ExprStmt, + ) -> QasmResult<(String, usize)> { + match &expr_stmt.expr { + Expr::Call(call) => self.convert_call(call), + Expr::Gate(gate) => self.convert_gate_expr(gate), + Expr::Measure(measure) => self.convert_measure_expr(measure), + _ => Ok((String::new(), 0)), + } + } + + fn convert_gate_expr(&mut self, gate: &crate::ast::GateExpr) -> QasmResult<(String, usize)> { + use crate::ast::GateKind; + use std::fmt::Write; + + // Map GateKind to QASM gate name (lowercase) + let gate_name: &str = match gate.kind { + GateKind::X => "x", + GateKind::Y => "y", + GateKind::Z => "z", + GateKind::H => "h", + GateKind::T => "t", + GateKind::Tdg => "tdg", + GateKind::SX => "sx", + GateKind::SY => "sy", + GateKind::SZ => "s", // QASM uses "s" for S gate + GateKind::SXdg => "sxdg", + GateKind::SYdg => "sydg", + GateKind::SZdg => "sdg", // QASM uses "sdg" for S-dagger + GateKind::RX => "rx", + GateKind::RY => "ry", + GateKind::RZ => "rz", + GateKind::CX => "cx", + GateKind::CY => "cy", + GateKind::CZ => "cz", + GateKind::CH => "ch", + GateKind::SWAP => "swap", + GateKind::ISWAP => "iswap", + GateKind::SXX => "sxx", + GateKind::SYY => "syy", + GateKind::SZZ => "szz", + GateKind::SXXdg => "sxxdg", + GateKind::SYYdg => "syydg", + GateKind::SZZdg => "szzdg", + GateKind::RZZ => "rzz", + GateKind::CCX => "ccx", + GateKind::F => "f", + GateKind::Fdg => "fdg", + GateKind::F4 => "f4", + GateKind::F4dg => "f4dg", + GateKind::PZ => return Ok((String::new(), 0)), // Prepare is implicit in QASM + }; + + let mut output = String::new(); + + // Handle batch targets (sets) + if let Expr::Set(set_expr) = &gate.target { + let gate_info = get_gate_info(gate_name).ok_or_else(|| QasmError::UnknownGate { + name: gate_name.to_string(), + })?; + let params: Vec = gate + .params + .iter() + .map(|p| self.convert_expression(p)) + .collect::>()?; + return self.convert_batch_gate(&gate_info, &set_expr.elements, ¶ms); + } + + // Convert parameters + let params: Vec = gate + .params + .iter() + .map(|p| self.convert_expression(p)) + .collect::>()?; + + // Convert target(s) + let targets = self.extract_gate_qubit_targets(&gate.target)?; + + // Format gate with optional parameters + if params.is_empty() { + write!(output, "{} ", gate_name)?; + } else { + write!(output, "{}({}) ", gate_name, params.join(", "))?; + } + + // Format targets + writeln!(output, "{};", targets.join(", "))?; + + Ok((output, 0)) + } + + fn convert_measure_expr( + &mut self, + measure: &crate::ast::MeasureExpr, + ) -> QasmResult<(String, usize)> { + use std::fmt::Write; + + let mut output = String::new(); + let targets = self.extract_gate_qubit_targets(&measure.targets)?; + + for (i, target) in targets.iter().enumerate() { + let bit_idx = self.creg_counter + i; + writeln!(output, "measure {} -> c[{}];", target, bit_idx)?; + } + + let count = targets.len(); + self.creg_counter += count; + Ok((output, count)) + } + + fn extract_gate_qubit_targets(&self, expr: &Expr) -> QasmResult> { + match expr { + Expr::Index(idx) => { + let allocator = self.extract_identifier(&idx.object)?; + let index = self.extract_integer(&idx.index)?; + let global_index = self.get_global_qubit_index(&allocator, index)?; + Ok(vec![format!("q[{}]", global_index)]) + } + Expr::Tuple(tuple) => { + let mut targets = Vec::new(); + for elem in &tuple.elements { + targets.extend(self.extract_gate_qubit_targets(elem)?); + } + Ok(targets) + } + Expr::BracketArray(arr) => { + let mut targets = Vec::new(); + for elem in &arr.elements { + targets.extend(self.extract_gate_qubit_targets(elem)?); + } + Ok(targets) + } + _ => Err(QasmError::UnsupportedExpression), + } + } + + fn convert_call(&mut self, call: &CallExpr) -> QasmResult<(String, usize)> { + let name = self.extract_call_name(&call.callee)?; + + // Check for special operations + match name.as_str() { + "mz" => return self.convert_measure(call), + "barrier" => return self.convert_barrier(call), + _ => {} + } + + // Check for gate calls + let Some(gate_info) = get_gate_info(&name) else { + return Ok((String::new(), 0)); + }; + + let mut output = String::new(); + + // For parameterized gates: qubits come first, then angle + // e.g., rz(q[0], 1.5708) or rz(&[q[0], q[1]], 1.5708) + let (params, qubit_args): (Vec, &[Expr]) = if gate_info.parameterized { + if call.args.len() < 2 { + return Err(QasmError::WrongArgumentCount { + gate: name, + expected: gate_info.arity + 1, + got: call.args.len(), + }); + } + // Last argument is the parameter (angle) + let param = self.convert_expression(call.args.last().unwrap())?; + // All but last are qubit args + (vec![param], &call.args[..call.args.len() - 1]) + } else { + (Vec::new(), &call.args[..]) + }; + + // Check for batch operations (set literal or address-of array) + if !qubit_args.is_empty() { + // Set literal: h([q[0], q[1]) + if let Expr::Set(set_expr) = &qubit_args[0] { + return self.convert_batch_gate(&gate_info, &set_expr.elements, ¶ms); + } + // Address-of array: h(&[q[0], q[1]]) + if let Expr::Unary(unary) = &qubit_args[0] + && let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand + { + return self.convert_batch_gate(&gate_info, &arr.elements, ¶ms); + } + } + + // Standard gate call + if qubit_args.len() != gate_info.arity { + return Err(QasmError::WrongArgumentCount { + gate: name, + expected: if gate_info.parameterized { + gate_info.arity + 1 + } else { + gate_info.arity + }, + got: call.args.len(), + }); + } + + // Build gate string + write!(output, "{}", gate_info.name)?; + + // Add parameters + if !params.is_empty() { + write!(output, "({})", params.join(", "))?; + } + + // Add qubit targets + write!(output, " ")?; + for (i, arg) in qubit_args.iter().enumerate() { + if i > 0 { + write!(output, ", ")?; + } + let (alloc, idx) = self.extract_qubit_ref(arg)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + write!(output, "q[{}]", global_idx)?; + } + writeln!(output, ";")?; + + Ok((output, 0)) + } + + fn convert_batch_gate( + &mut self, + gate_info: &GateInfo, + elements: &[Expr], + params: &[String], + ) -> QasmResult<(String, usize)> { + let mut output = String::new(); + + if gate_info.arity == 1 { + // Single-qubit gate on multiple qubits + for elem in elements { + let (alloc, idx) = self.extract_qubit_ref(elem)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + + write!(output, "{}", gate_info.name)?; + if !params.is_empty() { + write!(output, "({})", params.join(", "))?; + } + writeln!(output, " q[{}];", global_idx)?; + } + } else if gate_info.arity == 2 { + // Two-qubit gate with tuple pairs + for elem in elements { + if let Expr::Tuple(tuple) = elem { + if tuple.elements.len() == 2 { + let (alloc1, idx1) = self.extract_qubit_ref(&tuple.elements[0])?; + let (alloc2, idx2) = self.extract_qubit_ref(&tuple.elements[1])?; + let global1 = self.get_global_qubit_index(&alloc1, idx1)?; + let global2 = self.get_global_qubit_index(&alloc2, idx2)?; + + write!(output, "{}", gate_info.name)?; + if !params.is_empty() { + write!(output, "({})", params.join(", "))?; + } + writeln!(output, " q[{}], q[{}];", global1, global2)?; + } else { + return Err(QasmError::UnsupportedExpression); + } + } else { + return Err(QasmError::UnsupportedExpression); + } + } + } else { + return Err(QasmError::UnsupportedExpression); + } + + Ok((output, 0)) + } + + fn convert_measure(&mut self, call: &CallExpr) -> QasmResult<(String, usize)> { + let mut output = String::new(); + let mut measurement_count = 0; + + // Typed measurement: mz(type, target) + if call.args.len() == 2 { + let target_arg = &call.args[1]; + + match target_arg { + // Single qubit: q[0] + Expr::Index(_) => { + let (alloc, idx) = self.extract_qubit_ref(target_arg)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + let creg_idx = self.creg_counter; + self.creg_counter += 1; + writeln!(output, "measure q[{}] -> c[{}];", global_idx, creg_idx)?; + measurement_count = 1; + } + // Address-of array: &[q[0], q[1], ...] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand + { + for elem in &arr.elements { + let (alloc, idx) = self.extract_qubit_ref(elem)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + let creg_idx = self.creg_counter; + self.creg_counter += 1; + writeln!(output, "measure q[{}] -> c[{}];", global_idx, creg_idx)?; + measurement_count += 1; + } + } + } + _ => return Err(QasmError::UnsupportedExpression), + } + } else { + // Legacy: mz(q[0]) + for arg in &call.args { + let (alloc, idx) = self.extract_qubit_ref(arg)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + let creg_idx = self.creg_counter; + self.creg_counter += 1; + writeln!(output, "measure q[{}] -> c[{}];", global_idx, creg_idx)?; + measurement_count += 1; + } + } + + Ok((output, measurement_count)) + } + + fn convert_barrier(&mut self, call: &CallExpr) -> QasmResult<(String, usize)> { + let mut output = String::new(); + + if call.args.is_empty() { + // Barrier on all qubits + writeln!(output, "barrier q;")?; + } else { + // Barrier on specific allocators + write!(output, "barrier ")?; + let mut first = true; + for arg in &call.args { + if let Expr::Ident(ident) = arg + && let Some(alloc) = self.allocators.get(&ident.name) + { + for i in 0..alloc.capacity { + if !first { + write!(output, ", ")?; + } + first = false; + write!(output, "q[{}]", alloc.offset + i)?; + } + } + } + writeln!(output, ";")?; + } + + Ok((output, 0)) + } + + fn convert_expression(&self, expr: &Expr) -> QasmResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value.to_string()), + Expr::FloatLit(lit) => Ok(format!("{}", lit.value)), + Expr::Ident(ident) => { + // Check for built-in constants + match ident.name.as_str() { + "pi" | "PI" => Ok("pi".to_string()), + "tau" | "TAU" => Ok("2*pi".to_string()), + _ => Ok(ident.name.clone()), + } + } + Expr::Binary(binary) => { + let left = self.convert_expression(&binary.left)?; + let right = self.convert_expression(&binary.right)?; + let op = match binary.op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Mul => "*", + BinaryOp::Div => "/", + _ => return Err(QasmError::UnsupportedExpression), + }; + Ok(format!("({} {} {})", left, op, right)) + } + Expr::Unary(unary) => { + let operand = self.convert_expression(&unary.operand)?; + match unary.op { + crate::ast::UnaryOp::Neg => Ok(format!("-{}", operand)), + _ => Err(QasmError::UnsupportedExpression), + } + } + _ => Err(QasmError::UnsupportedExpression), + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + fn try_extract_allocator(&self, expr: &Expr) -> Option { + if let Expr::Call(call) = expr { + let name = self.extract_call_name(&call.callee).ok()?; + if name == "qalloc" && call.args.len() == 1 { + return self.extract_integer(&call.args[0]).ok(); + } + } + None + } + + fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { + if let Expr::Call(call) = expr + && let Expr::Field(field) = &call.callee + && field.field == "child" + && call.args.len() == 1 + { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } + None + } + + fn extract_call_name(&self, callee: &Expr) -> QasmResult { + match callee { + Expr::Ident(ident) => Ok(ident.name.clone()), + Expr::Field(field) => Ok(field.field.clone()), + _ => Err(QasmError::UnsupportedExpression), + } + } + + fn extract_identifier(&self, expr: &Expr) -> QasmResult { + match expr { + Expr::Ident(ident) => Ok(ident.name.clone()), + _ => Err(QasmError::UnsupportedExpression), + } + } + + fn extract_qubit_ref(&self, expr: &Expr) -> QasmResult<(String, usize)> { + match expr { + Expr::Index(index) => { + let allocator = self.extract_identifier(&index.object)?; + let idx = self.extract_integer(&index.index)?; + Ok((allocator, idx)) + } + _ => Err(QasmError::UnsupportedExpression), + } + } + + fn get_global_qubit_index(&self, allocator: &str, index: usize) -> QasmResult { + let alloc = + self.allocators + .get(allocator) + .ok_or_else(|| QasmError::UndefinedAllocator { + name: allocator.to_string(), + })?; + + if index >= alloc.capacity { + return Err(QasmError::QubitIndexOutOfBounds { + allocator: allocator.to_string(), + index, + capacity: alloc.capacity, + }); + } + + Ok(alloc.offset + index) + } + + fn extract_integer(&self, expr: &Expr) -> QasmResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value as usize), + _ => Err(QasmError::UnsupportedExpression), + } + } +} + +impl Default for QasmCodegen { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + fn compile_to_qasm(source: &str) -> QasmResult { + let program = parse(source).expect("parse failed"); + let mut codegen = QasmCodegen::new(); + codegen.compile(&program) + } + + #[test] + fn test_empty_program() { + let qasm = compile_to_qasm("").unwrap(); + assert!(qasm.contains("OPENQASM 2.0;")); + } + + #[test] + fn test_single_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("qreg q[1];")); + assert!(qasm.contains("h q[0];")); + } + + #[test] + fn test_bell_state() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("qreg q[2];")); + assert!(qasm.contains("h q[0];")); + assert!(qasm.contains("cx q[0], q[1];")); + } + + #[test] + fn test_rotation_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + rz(1.57) q[0]; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("rz(1.57) q[0];")); + } + + #[test] + fn test_measurement() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + r := mz(u1) q[0]; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("creg c[1];")); + assert!(qasm.contains("measure q[0] -> c[0];")); + } + + #[test] + fn test_batch_gate() { + // Use new batch gate syntax: h {targets} + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + h {q[0], q[1], q[2]}; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("h q[0];")); + assert!(qasm.contains("h q[1];")); + assert!(qasm.contains("h q[2];")); + } + + #[test] + fn test_batch_cx() { + // Use new batch gate syntax: cx {(ctrl, target), ...} + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + cx {(q[0], q[1]), (q[2], q[3])}; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("cx q[0], q[1];")); + assert!(qasm.contains("cx q[2], q[3];")); + } + + #[test] + fn test_pi_constant() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + rz(pi / 4) q[0]; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("rz((pi / 4)) q[0];")); + } + + #[test] + fn test_tick_flattened() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick { + h q[0]; + h q[1]; + } + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + // Tick blocks are flattened in QASM + assert!(qasm.contains("h q[0];")); + assert!(qasm.contains("h q[1];")); + } + + #[test] + fn test_multiple_allocators() { + let source = r#" + pub fn main() -> unit { + mut data := qalloc(2); + mut ancilla := qalloc(1); + h data[0]; + cx (data[0], ancilla[0]); + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + // Total qubits = 2 + 1 = 3 + assert!(qasm.contains("qreg q[3];")); + // data[0] = q[0], ancilla[0] = q[2] + assert!(qasm.contains("h q[0];")); + assert!(qasm.contains("cx q[0], q[2];")); + } +} diff --git a/exp/zlup/src/codegen/slr.rs b/exp/zlup/src/codegen/slr.rs new file mode 100644 index 000000000..0a1ff2aa0 --- /dev/null +++ b/exp/zlup/src/codegen/slr.rs @@ -0,0 +1,4031 @@ +//! SLR-AST code generation for Zluppy. +//! +//! This module generates SLR-AST JSON from Zluppy AST. The output maps directly +//! to Python's frozen dataclass structure for seamless interop with PECOS. +//! +//! ## Design Philosophy +//! +//! Low-level but safe. The JSON output is: +//! - Explicit: Every node has a `type` field - no magic +//! - Simple: Flat structure, predictable format +//! - Constrained: Only valid SLR-AST constructs can be generated +//! +//! ## Output Format +//! +//! The generated JSON maps 1:1 to Python's SLR-AST dataclasses: +//! +//! ```json +//! { +//! "type": "Program", +//! "name": "main", +//! "allocator": {"type": "AllocatorDecl", "name": "q", "capacity": 2}, +//! "declarations": [...], +//! "body": [ +//! {"type": "GateOp", "gate": "H", "targets": [{"type": "SlotRef", "allocator": "q", "index": 0}] +//! ], +//! "returns": [] +//! } +//! ``` + +use std::collections::BTreeMap; +use thiserror::Error; + +use crate::ast::{ + BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, ForRange, ForStmt, IfStmt, + IndexExpr, Program, Stmt, TopLevelDecl, +}; + +// ============================================================================= +// Errors +// ============================================================================= + +/// SLR-AST code generation errors. +#[derive(Debug, Error)] +pub enum SlrError { + #[error("unknown gate '{name}'")] + UnknownGate { name: String }, + + #[error("undefined allocator '{name}'")] + UndefinedAllocator { name: String }, + + #[error( + "qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}" + )] + QubitIndexOutOfBounds { + allocator: String, + index: usize, + capacity: usize, + }, + + #[error("expected {expected} arguments for gate '{gate}', got {got}")] + WrongArgumentCount { + gate: String, + expected: usize, + got: usize, + }, + + #[error("unsupported expression in SLR codegen")] + UnsupportedExpression, + + #[error("invalid rotation angle")] + InvalidAngle, + + #[error("JSON serialization error: {0}")] + JsonError(String), +} + +/// Result type for SLR-AST code generation. +pub type SlrResult = Result; + +// ============================================================================= +// SLR-AST Node Types +// ============================================================================= + +/// SLR-AST Program node. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrProgram { + #[serde(rename = "type")] + pub node_type: &'static str, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub allocator: Option, + pub declarations: Vec, + /// External function declarations (FFI) + #[serde(skip_serializing_if = "Vec::is_empty")] + pub externs: Vec, + pub body: Vec, + pub returns: Vec, +} + +impl SlrProgram { + pub fn new(name: impl Into) -> Self { + Self { + node_type: "Program", + name: name.into(), + allocator: None, + declarations: Vec::new(), + externs: Vec::new(), + body: Vec::new(), + returns: Vec::new(), + } + } +} + +/// SLR-AST allocator declaration. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrAllocatorDecl { + #[serde(rename = "type")] + pub node_type: &'static str, + pub name: String, + pub capacity: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, +} + +impl SlrAllocatorDecl { + pub fn new(name: impl Into, capacity: usize) -> Self { + Self { + node_type: "AllocatorDecl", + name: name.into(), + capacity, + parent: None, + } + } + + pub fn with_parent(mut self, parent: impl Into) -> Self { + self.parent = Some(parent.into()); + self + } +} + +/// SLR-AST register declaration (classical bits). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrRegisterDecl { + #[serde(rename = "type")] + pub node_type: &'static str, + pub name: String, + pub size: usize, + pub is_result: bool, +} + +impl SlrRegisterDecl { + pub fn new(name: impl Into, size: usize) -> Self { + Self { + node_type: "RegisterDecl", + name: name.into(), + size, + is_result: true, + } + } +} + +/// SLR-AST declaration (allocator or register). +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrDeclaration { + Allocator(SlrAllocatorDecl), + Register(SlrRegisterDecl), +} + +/// SLR-AST slot reference (qubit in allocator). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrSlotRef { + #[serde(rename = "type")] + pub node_type: &'static str, + pub allocator: String, + pub index: usize, +} + +impl SlrSlotRef { + pub fn new(allocator: impl Into, index: usize) -> Self { + Self { + node_type: "SlotRef", + allocator: allocator.into(), + index, + } + } +} + +/// SLR-AST bit reference (classical bit in register). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBitRef { + #[serde(rename = "type")] + pub node_type: &'static str, + pub register: String, + pub index: usize, +} + +impl SlrBitRef { + pub fn new(register: impl Into, index: usize) -> Self { + Self { + node_type: "BitRef", + register: register.into(), + index, + } + } +} + +/// SLR-AST gate operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrGateOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub gate: &'static str, + pub targets: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub params: Vec, + /// Attributes for this gate (e.g., syndrome type, layer info) + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub attrs: std::collections::BTreeMap, +} + +impl SlrGateOp { + pub fn new(gate: &'static str, targets: Vec) -> Self { + Self { + node_type: "GateOp", + gate, + targets, + params: Vec::new(), + attrs: std::collections::BTreeMap::new(), + } + } + + pub fn with_params(mut self, params: Vec) -> Self { + self.params = params; + self + } + + pub fn with_attrs( + mut self, + attrs: std::collections::BTreeMap, + ) -> Self { + self.attrs = attrs; + self + } +} + +/// SLR-AST prepare operation (reset qubits). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrPrepareOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub allocator: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub slots: Option>, +} + +impl SlrPrepareOp { + pub fn all(allocator: impl Into) -> Self { + Self { + node_type: "PrepareOp", + allocator: allocator.into(), + slots: None, + } + } + + pub fn slots(allocator: impl Into, slots: Vec) -> Self { + Self { + node_type: "PrepareOp", + allocator: allocator.into(), + slots: Some(slots), + } + } +} + +/// SLR-AST measure operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrMeasureOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub targets: Vec, + pub results: Vec, + /// Result type for each measurement (u1, u8, or u64) + #[serde(skip_serializing_if = "Option::is_none")] + pub result_type: Option, +} + +impl SlrMeasureOp { + pub fn new(targets: Vec, results: Vec) -> Self { + Self { + node_type: "MeasureOp", + targets, + results, + result_type: None, + } + } + + pub fn with_result_type( + targets: Vec, + results: Vec, + result_type: &str, + ) -> Self { + Self { + node_type: "MeasureOp", + targets, + results, + result_type: Some(result_type.to_string()), + } + } +} + +/// SLR-AST barrier operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBarrierOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub allocators: Vec, +} + +impl SlrBarrierOp { + pub fn new(allocators: Vec) -> Self { + Self { + node_type: "BarrierOp", + allocators, + } + } +} + +/// SLR-AST swap operation. +/// Swaps two values in place: @swap(&a, &b) +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrSwapOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub a: SlrExpression, + pub b: SlrExpression, +} + +impl SlrSwapOp { + pub fn new(a: SlrExpression, b: SlrExpression) -> Self { + Self { + node_type: "SwapOp", + a, + b, + } + } +} + +/// SLR-AST if statement. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrIfStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + pub condition: SlrExpression, + pub then_body: Vec, + pub else_body: Vec, +} + +impl SlrIfStmt { + pub fn new(condition: SlrExpression, then_body: Vec) -> Self { + Self { + node_type: "IfStmt", + condition, + then_body, + else_body: Vec::new(), + } + } + + pub fn with_else(mut self, else_body: Vec) -> Self { + self.else_body = else_body; + self + } +} + +/// SLR-AST for statement (bounded iteration - NASA Power of 10 compliant). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrForStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + pub variable: String, + pub start: SlrExpression, + pub end: SlrExpression, + pub body: Vec, +} + +impl SlrForStmt { + pub fn new( + variable: impl Into, + start: SlrExpression, + end: SlrExpression, + body: Vec, + ) -> Self { + Self { + node_type: "ForStmt", + variable: variable.into(), + start, + end, + body, + } + } +} + +/// SLR-AST repeat statement (fixed iteration count). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrRepeatStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + pub count: usize, + pub body: Vec, +} + +impl SlrRepeatStmt { + pub fn new(count: usize, body: Vec) -> Self { + Self { + node_type: "RepeatStmt", + count, + body, + } + } +} + +/// SLR-AST attribute value. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrAttributeValue { + Bool(bool), + Int(i64), + Float(f64), + String(String), +} + +/// SLR-AST tick statement (parallel gate layer). +/// Represents a time slice where all gates execute in parallel. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrTickStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Optional label for the tick (e.g., "syndrome_round_1") + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Attributes for this tick (e.g., round number, tick type) + /// Using BTreeMap for deterministic ordering + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub attrs: std::collections::BTreeMap, + /// Gates/operations within this tick (execute in parallel) + pub body: Vec, +} + +impl SlrTickStmt { + pub fn new(body: Vec) -> Self { + Self { + node_type: "TickStmt", + label: None, + attrs: std::collections::BTreeMap::new(), + body, + } + } + + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn with_attrs( + mut self, + attrs: std::collections::BTreeMap, + ) -> Self { + self.attrs = attrs; + self + } +} + +/// SLR-AST log statement for debugging and tracing. +/// +/// Log statements are controlled by ZLUP_LOG environment variable at runtime. +/// In release builds, they can be elided entirely at compile time. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrLogStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Log level: "trace", "debug", "info", "warn", "error", or numeric + pub level: SlrLogLevel, + /// Namespace for filtering (module path + optional sub-namespace) + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Message expression (usually an f-string) + pub message: SlrExpression, + /// Optional structured data + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl SlrLogStmt { + pub fn new(level: SlrLogLevel, message: SlrExpression) -> Self { + Self { + node_type: "LogStmt", + level, + namespace: None, + message, + data: None, + } + } + + pub fn with_namespace(mut self, namespace: impl Into) -> Self { + self.namespace = Some(namespace.into()); + self + } + + pub fn with_data(mut self, data: SlrExpression) -> Self { + self.data = Some(data); + self + } +} + +/// Log level for SLR log statements. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrLogLevel { + /// Standard named level + Standard(String), + /// Custom numeric level + Numeric(i64), +} + +/// SLR-AST send statement for out-of-band communication. +/// +/// Unified type for `result(key, value)` and `sim.send(key, value)`. +/// The channel determines how the message is handled: +/// - "result": Program output (never elided) +/// - "sim": Simulator control (elided for hardware, emits barrier by default) +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrSendStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Channel: "result", "sim" + pub channel: String, + /// Key identifying the message (e.g., "counts", "noise_enable") + pub key: String, + /// Optional value expression + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +impl SlrSendStmt { + pub fn new(channel: impl Into, key: impl Into) -> Self { + Self { + node_type: "SendStmt", + channel: channel.into(), + key: key.into(), + value: None, + } + } + + pub fn with_value(mut self, value: SlrExpression) -> Self { + self.value = Some(value); + self + } + + /// Create a result send (program output). + pub fn result(key: impl Into, value: SlrExpression) -> Self { + Self::new("result", key).with_value(value) + } + + /// Create a sim send (simulator control). + pub fn sim(key: impl Into) -> Self { + Self::new("sim", key) + } + + /// Create a sim send with value. + pub fn sim_with_value(key: impl Into, value: SlrExpression) -> Self { + Self::new("sim", key).with_value(value) + } +} + +/// SLR-AST statement (union of all statement types). +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrStatement { + Gate(SlrGateOp), + Prepare(SlrPrepareOp), + Measure(SlrMeasureOp), + Barrier(SlrBarrierOp), + If(SlrIfStmt), + For(SlrForStmt), + Repeat(SlrRepeatStmt), + Tick(SlrTickStmt), + ExternCall(SlrExternCall), + Log(SlrLogStmt), + Send(SlrSendStmt), + Swap(SlrSwapOp), +} + +/// SLR-AST external function declaration (FFI). +/// Declares an external function that can be called from Zlup code. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrExternDecl { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Function name + pub name: String, + /// Library to link against (e.g., "libdecoder") + #[serde(skip_serializing_if = "Option::is_none")] + pub library: Option, + /// Calling convention ("C" or "Rust") + pub calling_convention: String, + /// Parameter declarations with C-compatible type info + pub params: Vec, + /// Return type in C-compatible format + #[serde(skip_serializing_if = "Option::is_none")] + pub return_type: Option, +} + +impl SlrExternDecl { + pub fn new( + name: String, + library: Option, + calling_convention: String, + params: Vec, + return_type: Option, + ) -> Self { + Self { + node_type: "ExternDecl", + name, + library, + calling_convention, + params, + return_type, + } + } +} + +/// Parameter for an external function. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrExternParam { + pub name: String, + pub ctype: SlrCType, +} + +/// C-compatible type representation for FFI. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "kind")] +pub enum SlrCType { + /// Primitive integer types + #[serde(rename = "int")] + Int { bits: u8, signed: bool }, + /// Floating point types + #[serde(rename = "float")] + Float { bits: u8 }, + /// Fixed-point angle type (maps to PECOS Angle64) + /// Angles are represented as fractions of a full turn. + #[serde(rename = "angle")] + Angle { bits: u8 }, + /// Pointer type + #[serde(rename = "pointer")] + Pointer { + element: Box, + is_const: bool, + }, + /// Void type (for return) + #[serde(rename = "void")] + Void, + /// Opaque type (user-defined struct, passed by name) + #[serde(rename = "opaque")] + Opaque { name: String }, +} + +/// SLR-AST external function call. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrExternCall { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Name of the external function to call + pub function: String, + /// Arguments to pass + pub args: Vec, + /// Optional: variable to store result + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +impl SlrExternCall { + pub fn new(function: String, args: Vec, result: Option) -> Self { + Self { + node_type: "ExternCall", + function, + args, + result, + } + } +} + +/// SLR-AST expression. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrExpression { + Literal(SlrLiteralExpr), + Var(SlrVarExpr), + Bit(SlrBitExpr), + Binary(SlrBinaryExpr), + Unary(SlrUnaryExpr), + FString(SlrFStringExpr), + ExternCall(Box), +} + +/// SLR-AST literal expression. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrLiteralExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub value: SlrLiteralValue, +} + +/// Literal value variants. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrLiteralValue { + Int(i64), + Float(f64), + Bool(bool), + /// String value + String(String), + /// Angle value in turns (for a64 type) + Angle(f64), +} + +impl SlrLiteralExpr { + pub fn int(value: i64) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::Int(value), + } + } + + pub fn float(value: f64) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::Float(value), + } + } + + pub fn bool(value: bool) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::Bool(value), + } + } + + /// Create an angle literal (value in turns) + pub fn angle(turns: f64) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::Angle(turns), + } + } + + pub fn string(value: impl Into) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::String(value.into()), + } + } +} + +/// SLR-AST f-string expression (string interpolation). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrFStringExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Parts of the f-string (text and expression parts) + pub parts: Vec, +} + +impl SlrFStringExpr { + pub fn new(parts: Vec) -> Self { + Self { + node_type: "FStringExpr", + parts, + } + } +} + +/// Part of an f-string. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "kind")] +pub enum SlrFStringPart { + /// Literal text + #[serde(rename = "text")] + Text { value: String }, + /// Interpolated expression + #[serde(rename = "expr")] + Expr { + value: Box, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, + }, +} + +/// SLR-AST variable expression. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrVarExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub name: String, +} + +impl SlrVarExpr { + pub fn new(name: impl Into) -> Self { + Self { + node_type: "VarExpr", + name: name.into(), + } + } +} + +/// SLR-AST bit expression (for conditions). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBitExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub register: String, + pub index: usize, +} + +impl SlrBitExpr { + pub fn new(register: impl Into, index: usize) -> Self { + Self { + node_type: "BitExpr", + register: register.into(), + index, + } + } +} + +/// SLR-AST binary expression. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBinaryExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub op: &'static str, + pub left: Box, + pub right: Box, +} + +impl SlrBinaryExpr { + pub fn new(op: &'static str, left: SlrExpression, right: SlrExpression) -> Self { + Self { + node_type: "BinaryExpr", + op, + left: Box::new(left), + right: Box::new(right), + } + } +} + +/// SLR-AST unary expression. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrUnaryExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub op: &'static str, + pub operand: Box, +} + +impl SlrUnaryExpr { + pub fn new(op: &'static str, operand: SlrExpression) -> Self { + Self { + node_type: "UnaryExpr", + op, + operand: Box::new(operand), + } + } +} + +/// SLR-AST type expression (for return types). +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrTypeExpr { + Qubit(SlrQubitType), + Bit(SlrBitType), +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrQubitType { + #[serde(rename = "type")] + pub node_type: &'static str, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBitType { + #[serde(rename = "type")] + pub node_type: &'static str, +} + +// ============================================================================= +// Gate Mapping +// ============================================================================= + +/// Gate information for SLR-AST. +struct GateInfo { + /// Gate name in SLR-AST (matches GateKind enum). + name: &'static str, + /// Number of qubit targets. + arity: usize, + /// Whether this gate takes parameters. + parameterized: bool, +} + +/// Get the arity (number of qubit targets) for a gate kind. +fn gate_kind_arity(kind: &crate::ast::GateKind) -> usize { + use crate::ast::GateKind; + match kind { + // Single-qubit gates + GateKind::X + | GateKind::Y + | GateKind::Z + | GateKind::H + | GateKind::T + | GateKind::Tdg + | GateKind::SX + | GateKind::SY + | GateKind::SZ + | GateKind::SXdg + | GateKind::SYdg + | GateKind::SZdg + | GateKind::RX + | GateKind::RY + | GateKind::RZ + | GateKind::F + | GateKind::Fdg + | GateKind::F4 + | GateKind::F4dg + | GateKind::PZ => 1, + // Two-qubit gates + GateKind::CX + | GateKind::CY + | GateKind::CZ + | GateKind::CH + | GateKind::SWAP + | GateKind::ISWAP + | GateKind::SXX + | GateKind::SYY + | GateKind::SZZ + | GateKind::SXXdg + | GateKind::SYYdg + | GateKind::SZZdg + | GateKind::RZZ => 2, + // Three-qubit gates + GateKind::CCX => 3, + } +} + +/// Maps Zluppy gate names to SLR-AST gate info. +/// +/// Zluppy uses lowercase gate names only. +/// The output name in SLR-AST remains uppercase for compatibility with downstream tools. +fn get_gate_info(name: &str) -> Option { + match name { + // Single-qubit Pauli gates (lowercase only) + "x" => Some(GateInfo { + name: "X", + arity: 1, + parameterized: false, + }), + "y" => Some(GateInfo { + name: "Y", + arity: 1, + parameterized: false, + }), + "z" => Some(GateInfo { + name: "Z", + arity: 1, + parameterized: false, + }), + + // Hadamard + "h" => Some(GateInfo { + name: "H", + arity: 1, + parameterized: false, + }), + + // Square root gates (sx = sqrt(X), sy = sqrt(Y), sz = sqrt(Z)) + "sx" => Some(GateInfo { + name: "SX", + arity: 1, + parameterized: false, + }), + "sy" => Some(GateInfo { + name: "SY", + arity: 1, + parameterized: false, + }), + "sz" => Some(GateInfo { + name: "SZ", + arity: 1, + parameterized: false, + }), + "sxdg" => Some(GateInfo { + name: "SXdg", + arity: 1, + parameterized: false, + }), + "sydg" => Some(GateInfo { + name: "SYdg", + arity: 1, + parameterized: false, + }), + "szdg" => Some(GateInfo { + name: "SZdg", + arity: 1, + parameterized: false, + }), + + // T gates (fourth root of Z) + "t" => Some(GateInfo { + name: "T", + arity: 1, + parameterized: false, + }), + "tdg" => Some(GateInfo { + name: "Tdg", + arity: 1, + parameterized: false, + }), + + // Rotation gates (parameterized) + "rx" => Some(GateInfo { + name: "RX", + arity: 1, + parameterized: true, + }), + "ry" => Some(GateInfo { + name: "RY", + arity: 1, + parameterized: true, + }), + "rz" => Some(GateInfo { + name: "RZ", + arity: 1, + parameterized: true, + }), + + // Two-qubit Clifford + "cx" => Some(GateInfo { + name: "CX", + arity: 2, + parameterized: false, + }), + "cy" => Some(GateInfo { + name: "CY", + arity: 2, + parameterized: false, + }), + "cz" => Some(GateInfo { + name: "CZ", + arity: 2, + parameterized: false, + }), + "ch" => Some(GateInfo { + name: "CH", + arity: 2, + parameterized: false, + }), + + // Two-qubit rotation (parameterized) + "rzz" => Some(GateInfo { + name: "RZZ", + arity: 2, + parameterized: true, + }), + + // Two-qubit Ising + "sxx" => Some(GateInfo { + name: "SXX", + arity: 2, + parameterized: false, + }), + "syy" => Some(GateInfo { + name: "SYY", + arity: 2, + parameterized: false, + }), + "szz" => Some(GateInfo { + name: "SZZ", + arity: 2, + parameterized: false, + }), + + // Face rotations + "f" => Some(GateInfo { + name: "F", + arity: 1, + parameterized: false, + }), + "fdg" => Some(GateInfo { + name: "Fdg", + arity: 1, + parameterized: false, + }), + "f4" => Some(GateInfo { + name: "F4", + arity: 1, + parameterized: false, + }), + "f4dg" => Some(GateInfo { + name: "F4dg", + arity: 1, + parameterized: false, + }), + + // Two-qubit controlled rotation (parameterized) + "crz" => Some(GateInfo { + name: "CRZ", + arity: 2, + parameterized: true, + }), + + // Swap gates + "swap" => Some(GateInfo { + name: "SWAP", + arity: 2, + parameterized: false, + }), + "iswap" => Some(GateInfo { + name: "iSWAP", + arity: 2, + parameterized: false, + }), + + // Three-qubit gates + "ccx" => Some(GateInfo { + name: "CCX", + arity: 3, + parameterized: false, + }), + + // Two-qubit Ising dagger gates + "sxxdg" => Some(GateInfo { + name: "SXXdg", + arity: 2, + parameterized: false, + }), + "syydg" => Some(GateInfo { + name: "SYYdg", + arity: 2, + parameterized: false, + }), + "szzdg" => Some(GateInfo { + name: "SZZdg", + arity: 2, + parameterized: false, + }), + + _ => None, + } +} + +// ============================================================================= +// Binary/Unary Operator Mapping +// ============================================================================= + +fn binary_op_to_slr(op: &BinaryOp) -> &'static str { + match op { + BinaryOp::Add => "ADD", + BinaryOp::Sub => "SUB", + BinaryOp::Mul => "MUL", + BinaryOp::Div => "DIV", + BinaryOp::Mod => "MOD", + BinaryOp::Eq => "EQ", + BinaryOp::Ne => "NE", + BinaryOp::Lt => "LT", + BinaryOp::Le => "LE", + BinaryOp::Gt => "GT", + BinaryOp::Ge => "GE", + BinaryOp::And => "AND", + BinaryOp::Or => "OR", + BinaryOp::Orelse => "ORELSE", + BinaryOp::BitAnd => "AND", + BinaryOp::BitOr => "OR", + BinaryOp::BitXor => "XOR", + BinaryOp::Shl => "LSHIFT", + BinaryOp::Shr => "RSHIFT", + // Set membership operators - these are handled specially, not as SLR ops + BinaryOp::In => "IN", + BinaryOp::NotIn => "NOT_IN", + // Error handling - catch is control flow, not typically a direct SLR op + BinaryOp::Catch => "CATCH", + } +} + +// ============================================================================= +// Code Generator +// ============================================================================= + +/// Tracks an allocator during codegen. +#[derive(Debug, Clone)] +struct AllocatorInfo { + name: String, + capacity: usize, + parent: Option, +} + +/// Tracks a register during codegen. +#[derive(Debug, Clone)] +struct RegisterInfo { + name: String, + size: usize, +} + +/// Log level threshold for compile-time elision. +/// +/// Standard levels are spaced 100 apart for custom levels in between: +/// - trace=0, debug=100, info=200, warn=300, error=400 +/// +/// Set to higher values to elide more logs at compile time. +#[derive(Debug, Clone, Copy, Default)] +pub struct LogElisionLevel(pub Option); + +impl LogElisionLevel { + /// No elision - emit all logs + pub const NONE: Self = Self(None); + /// Elide trace logs (keep debug and above) + pub const DEBUG: Self = Self(Some(100)); + /// Elide trace and debug logs (keep info and above) + pub const INFO: Self = Self(Some(200)); + /// Elide trace, debug, and info logs (keep warn and above) + pub const WARN: Self = Self(Some(300)); + /// Elide everything except errors + pub const ERROR: Self = Self(Some(400)); + /// Elide all logs (for release builds) + pub const ALL: Self = Self(Some(u32::MAX)); + + /// Check if a log level should be elided. + pub fn should_elide(&self, level: u32) -> bool { + match self.0 { + Some(threshold) => level < threshold, + None => false, + } + } +} + +/// SLR-AST code generator. +/// +/// Walks a Zluppy AST and produces SLR-AST JSON. +pub struct SlrCodegen { + /// Allocators by name. + allocators: BTreeMap, + /// Registers by name. + registers: BTreeMap, + /// Auto-generated register counter. + register_counter: usize, + /// Names of external functions (for call lookup). + extern_fns: std::collections::BTreeSet, + /// Current module path for log namespace. + current_module: Option, + /// Minimum log level to emit (for compile-time elision). + log_elision: LogElisionLevel, + /// How to handle sim commands for non-simulator targets. + sim_mode: SimMode, +} + +/// How simulator commands are handled during code generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SimMode { + /// Emit actual SimStmt (simulator target). + #[default] + Emit, + /// Emit a barrier/no-op to preserve ordering (hardware default). + Barrier, + /// Completely elide - no output at all (explicit opt-in). + Elide, +} + +impl SlrCodegen { + /// Create a new SLR-AST code generator. + pub fn new() -> Self { + Self { + allocators: BTreeMap::new(), + registers: BTreeMap::new(), + register_counter: 0, + extern_fns: std::collections::BTreeSet::new(), + current_module: None, + log_elision: LogElisionLevel::NONE, + sim_mode: SimMode::Emit, + } + } + + /// Set the log elision level for compile-time removal of log statements. + /// + /// # Example + /// + /// ```ignore + /// let mut codegen = SlrCodegen::new(); + /// // Elide all logs below info level + /// codegen.set_log_elision(LogElisionLevel::INFO); + /// ``` + pub fn set_log_elision(&mut self, level: LogElisionLevel) { + self.log_elision = level; + } + + /// Set how sim commands are handled. + /// + /// - `SimMode::Emit` - Output actual SimStmt (simulator target) + /// - `SimMode::Barrier` - Output barrier to preserve ordering (hardware default) + /// - `SimMode::Elide` - Completely remove (explicit opt-in for max optimization) + pub fn set_sim_mode(&mut self, mode: SimMode) { + self.sim_mode = mode; + } + + /// Create a new SLR-AST code generator with log elision for release builds. + pub fn new_release() -> Self { + let mut codegen = Self::new(); + codegen.log_elision = LogElisionLevel::ALL; + codegen + } + + /// Set the current module path for automatic log namespacing. + /// + /// The module path is used as the default namespace for log statements + /// that don't specify an explicit namespace. Sub-namespaces specified + /// in log statements are appended to this module path. + /// + /// # Example + /// + /// ```ignore + /// let mut codegen = SlrCodegen::new(); + /// codegen.set_module("myproject::syndromes"); + /// + /// // log.debug(f"msg") -> namespace: "myproject::syndromes" + /// // log.debug("round", f"msg") -> namespace: "myproject::syndromes::round" + /// ``` + pub fn set_module(&mut self, module: impl Into) { + self.current_module = Some(module.into()); + } + + /// Compile a Zluppy program to SLR-AST. + pub fn compile(&mut self, program: &Program) -> SlrResult { + let mut slr_program = SlrProgram::new("main"); + + // First pass: collect allocators and registers + for decl in &program.declarations { + self.collect_decl(decl)?; + } + + // Build declarations + for alloc in self.allocators.values() { + let mut decl = SlrAllocatorDecl::new(&alloc.name, alloc.capacity); + if let Some(ref parent) = alloc.parent { + decl = decl.with_parent(parent); + } + // Set base allocator if this is the first one without a parent + if alloc.parent.is_none() && slr_program.allocator.is_none() { + slr_program.allocator = Some(decl.clone()); + } + slr_program + .declarations + .push(SlrDeclaration::Allocator(decl)); + } + + for reg in self.registers.values() { + slr_program + .declarations + .push(SlrDeclaration::Register(SlrRegisterDecl::new( + ®.name, reg.size, + ))); + } + + // Collect extern function declarations + for decl in &program.declarations { + if let TopLevelDecl::ExternFn(extern_fn) = decl { + self.extern_fns.insert(extern_fn.name.clone()); + slr_program.externs.push(self.convert_extern_fn(extern_fn)?); + } + } + + // Second pass: convert statements + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl + && fn_decl.name == "main" + { + let body = self.convert_block(&fn_decl.body)?; + slr_program.body = body; + } + } + + Ok(slr_program) + } + + /// Compile a function to SLR-AST. + pub fn compile_function(&mut self, fn_decl: &FnDecl) -> SlrResult { + // Collect from function body first + self.collect_block(&fn_decl.body)?; + + let mut slr_program = SlrProgram::new(&fn_decl.name); + + // Build declarations + for alloc in self.allocators.values() { + let mut decl = SlrAllocatorDecl::new(&alloc.name, alloc.capacity); + if let Some(ref parent) = alloc.parent { + decl = decl.with_parent(parent); + } + if alloc.parent.is_none() && slr_program.allocator.is_none() { + slr_program.allocator = Some(decl.clone()); + } + slr_program + .declarations + .push(SlrDeclaration::Allocator(decl)); + } + + for reg in self.registers.values() { + slr_program + .declarations + .push(SlrDeclaration::Register(SlrRegisterDecl::new( + ®.name, reg.size, + ))); + } + + // Convert body + slr_program.body = self.convert_block(&fn_decl.body)?; + + Ok(slr_program) + } + + /// Convert to JSON string. + pub fn to_json(&self, program: &SlrProgram) -> SlrResult { + serde_json::to_string_pretty(program).map_err(|e| SlrError::JsonError(e.to_string())) + } + + /// Convert to compact JSON string. + pub fn to_json_compact(&self, program: &SlrProgram) -> SlrResult { + serde_json::to_string(program).map_err(|e| SlrError::JsonError(e.to_string())) + } + + // ========================================================================= + // Collection Phase + // ========================================================================= + + fn collect_decl(&mut self, decl: &TopLevelDecl) -> SlrResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) if fn_decl.name == "main" => { + self.collect_block(&fn_decl.body)?; + } + TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, + _ => {} + } + Ok(()) + } + + fn collect_binding(&mut self, binding: &Binding) -> SlrResult<()> { + if let Some(ref value) = binding.value { + if let Some(capacity) = self.try_extract_allocator(value) { + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity, + parent: None, + }, + ); + } else if let Some((parent, size)) = self.try_extract_child_allocator(value) { + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity: size, + parent: Some(parent), + }, + ); + } + } + Ok(()) + } + + fn collect_block(&mut self, block: &Block) -> SlrResult<()> { + for stmt in &block.statements { + self.collect_stmt(stmt)?; + } + Ok(()) + } + + fn collect_stmt(&mut self, stmt: &Stmt) -> SlrResult<()> { + match stmt { + Stmt::Binding(binding) => self.collect_binding(binding)?, + Stmt::If(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + Stmt::For(for_stmt) => self.collect_block(&for_stmt.body)?, + Stmt::Block(block) => self.collect_block(block)?, + Stmt::Tick(tick_stmt) => { + // Collect allocators from tick body + for inner_stmt in &tick_stmt.body { + self.collect_stmt(inner_stmt)?; + } + } + _ => {} + } + Ok(()) + } + + fn collect_else_branch(&mut self, branch: &ElseBranch) -> SlrResult<()> { + match branch { + ElseBranch::Else(block) => self.collect_block(block)?, + ElseBranch::ElseIf(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + } + Ok(()) + } + + // ========================================================================= + // Conversion Phase + // ========================================================================= + + fn convert_block(&mut self, block: &Block) -> SlrResult> { + let mut stmts = Vec::new(); + for stmt in &block.statements { + stmts.extend(self.convert_stmt(stmt)?); + } + Ok(stmts) + } + + fn convert_stmt(&mut self, stmt: &Stmt) -> SlrResult> { + match stmt { + Stmt::Expr(expr_stmt) => self.convert_expr_stmt(expr_stmt), + Stmt::If(if_stmt) => self.convert_if(if_stmt).map(|s| vec![s]), + Stmt::For(for_stmt) => self.convert_for(for_stmt).map(|s| vec![s]), + Stmt::Block(block) => { + // Nested block - flatten into parent + self.convert_block(block) + } + Stmt::Tick(tick_stmt) => self.convert_tick(tick_stmt).map(|s| vec![s]), + // Quantum operations + Stmt::Gate(gate_op) => self.convert_gate_op(gate_op).map(|s| vec![s]), + Stmt::Prepare(prepare_op) => self.convert_prepare_op(prepare_op).map(|s| vec![s]), + Stmt::Measure(measure_op) => self.convert_measure_op(measure_op).map(|s| vec![s]), + Stmt::Barrier(barrier_op) => self.convert_barrier_op(barrier_op).map(|s| vec![s]), + // Handle declarations - check for measurement calls in values + Stmt::Binding(binding) => { + if let Some(ref value) = binding.value { + self.convert_decl_value(value) + } else { + Ok(vec![]) + } + } + _ => Ok(vec![]), + } + } + + /// Convert a declaration value, extracting any measurement/gate calls. + fn convert_decl_value(&mut self, expr: &Expr) -> SlrResult> { + // Check for measure syntax: mz(T) targets + if let Expr::Measure(measure) = expr { + return self.convert_measure_expr(measure); + } + // Old call syntax mz(T, targets) is no longer supported + // Use mz(T) targets instead + Ok(vec![]) + } + + /// Convert new measure syntax: mz(T) targets + fn convert_measure_expr( + &mut self, + measure: &crate::ast::MeasureExpr, + ) -> SlrResult> { + let mut results = Vec::new(); + let mut targets = Vec::new(); + + // Extract result type from the type expression + let result_type = self.extract_measurement_result_type_from_type_expr(&measure.result_type); + + // Extract targets from the expression + match &measure.targets { + Expr::BracketArray(arr) => { + // Inline array: mz(u1) [q[0], q[1], q[2]] + for elem in &arr.elements { + let slot_ref = self.extract_slot_ref(elem)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + } + Expr::Index(_) => { + // Single qubit: mz(u1) q[0] + let slot_ref = self.extract_slot_ref(&measure.targets)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + _ => { + // Variable or slice - try to extract slot refs + return Err(SlrError::UnsupportedExpression); + } + } + + Ok(vec![SlrStatement::Measure(SlrMeasureOp::with_result_type( + targets, + results, + &result_type, + ))]) + } + + fn convert_expr_stmt( + &mut self, + expr_stmt: &crate::ast::ExprStmt, + ) -> SlrResult> { + // Convert attributes for gates + let attrs = self.convert_attributes(&expr_stmt.attrs); + + match &expr_stmt.expr { + Expr::Call(call) => self.convert_call_with_attrs(call, attrs), + Expr::BatchApply(batch) => self.convert_batch_apply_with_attrs(batch, attrs), + Expr::Gate(gate) => self.convert_gate_expr_with_attrs(gate, attrs), + Expr::Result(result) => self.convert_result_expr(result), + Expr::Channel(channel) => self.convert_channel_expr(channel), + Expr::Builtin(builtin) => self.convert_builtin_expr(builtin), + _ => Ok(vec![]), + } + } + + /// Convert builtin expressions like @swap. + fn convert_builtin_expr( + &self, + builtin: &crate::ast::BuiltinExpr, + ) -> SlrResult> { + match builtin.name.as_str() { + "swap" => { + // @swap(&a, &b) - emit as SwapOp + if builtin.args.len() != 2 { + return Err(SlrError::WrongArgumentCount { + gate: "swap".to_string(), + expected: 2, + got: builtin.args.len(), + }); + } + // Extract the underlying expressions (handling &x -> x) + let a = self.convert_swap_arg(&builtin.args[0])?; + let b = self.convert_swap_arg(&builtin.args[1])?; + Ok(vec![SlrStatement::Swap(SlrSwapOp::new(a, b))]) + } + _ => Ok(vec![]), // Other builtins may not produce SLR statements + } + } + + /// Convert a swap argument, unwrapping address-of if present. + fn convert_swap_arg(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::Unary(unary) => { + use crate::ast::UnaryOp; + match unary.op { + UnaryOp::AddrOf => { + // &x -> just use x + self.convert_expression(&unary.operand) + } + _ => self.convert_expression(expr), + } + } + _ => self.convert_expression(expr), + } + } + + /// Convert a result expression to SLR send statement. + /// + /// Result sends are never elided - they represent the actual program output. + fn convert_result_expr(&self, result: &crate::ast::ResultExpr) -> SlrResult> { + let value = self.convert_expression(&result.value)?; + Ok(vec![SlrStatement::Send(SlrSendStmt::result( + &result.tag, + value, + ))]) + } + + /// Convert a channel expression to SLR statements. + /// + /// Handles all @emit.channel.command(...) expressions: + /// - @emit.log.*: Logging with elision support + /// - @emit.sim.*: Simulator control with barrier/elide modes + /// - @emit.hw.*: Hardware messages (elided for simulator) + /// - Custom channels: Configurable behavior + fn convert_channel_expr( + &mut self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { + match channel.channel.as_str() { + "log" => self.convert_log_channel(channel), + "sim" => self.convert_sim_channel(channel), + "hw" => self.convert_hw_channel(channel), + _ => self.convert_custom_channel(channel), + } + } + + /// Convert @emit.log.* channel expressions. + fn convert_log_channel( + &self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { + // Map command to log level + let (level, numeric_level, level_consumes_arg) = match channel.command.as_str() { + "trace" => (SlrLogLevel::Standard("trace".to_string()), 0u32, false), + "debug" => (SlrLogLevel::Standard("debug".to_string()), 100, false), + "info" => (SlrLogLevel::Standard("info".to_string()), 200, false), + "warn" => (SlrLogLevel::Standard("warn".to_string()), 300, false), + "error" => (SlrLogLevel::Standard("error".to_string()), 400, false), + "at" => { + // @emit.log.at(level, message) or @emit.log.at(level, ns, message) - first arg is level + if let Some(level_arg) = channel.args.first() { + if let Ok(SlrExpression::Literal(SlrLiteralExpr { + value: SlrLiteralValue::Int(n), + .. + })) = self.convert_expression(level_arg.value()) + { + (SlrLogLevel::Numeric(n), n as u32, true) + } else { + (SlrLogLevel::Standard("custom".to_string()), u32::MAX, true) + } + } else { + return Ok(vec![]); // Invalid, skip + } + } + _ => return Ok(vec![]), // Unknown log command + }; + + // Check if this log should be elided + if self.log_elision.should_elide(numeric_level) { + return Ok(vec![]); + } + + // Start index after the level argument (if "at" command) + let start_idx = if level_consumes_arg { 1 } else { 0 }; + + // Determine namespace and message + // If first positional is string literal AND there's another arg, first is namespace + let (sub_namespace, message) = { + let remaining: Vec<_> = channel + .args + .iter() + .skip(start_idx) + .filter(|arg| arg.name() != Some("data")) + .collect(); + + if remaining.len() >= 2 { + // Check if first is plain string literal (namespace) + if let crate::ast::Expr::StringLit(s) = remaining[0].value() { + let ns = Some(s.value.clone()); + let msg = self.convert_expression(remaining[1].value())?; + (ns, msg) + } else { + (None, self.convert_expression(remaining[0].value())?) + } + } else if !remaining.is_empty() { + (None, self.convert_expression(remaining[0].value())?) + } else { + return Ok(vec![]); // No message + } + }; + + let mut stmt = SlrLogStmt::new(level, message); + + // Combine module namespace with sub-namespace + let full_namespace = match (&self.current_module, &sub_namespace) { + (Some(module), Some(sub)) => Some(format!("{}::{}", module, sub)), + (Some(module), None) => Some(module.clone()), + (None, Some(sub)) => Some(sub.clone()), + (None, None) => None, + }; + if let Some(ns) = full_namespace { + stmt = stmt.with_namespace(ns); + } + + // Check for named "data" argument + for arg in &channel.args { + if arg.name() == Some("data") { + stmt = stmt.with_data(self.convert_expression(arg.value())?); + } + } + + Ok(vec![SlrStatement::Log(stmt)]) + } + + /// Convert @emit.sim.* channel expressions. + fn convert_sim_channel( + &mut self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { + match self.sim_mode { + SimMode::Elide => Ok(vec![]), + SimMode::Barrier => { + let scope_allocators: Vec = self.allocators.keys().cloned().collect(); + Ok(vec![SlrStatement::Barrier(SlrBarrierOp::new( + scope_allocators, + ))]) + } + SimMode::Emit => { + // Handle @emit.sim.send(key, value) specially - key comes from first arg + if channel.command == "send" { + if channel.args.len() >= 2 { + // Extract key from first arg (should be string literal) + let key_expr = channel.args[0].value(); + let key = if let crate::ast::Expr::StringLit(s) = key_expr { + s.value.clone() + } else { + return Err(SlrError::UnsupportedExpression); + }; + let value = self.convert_expression(channel.args[1].value())?; + return Ok(vec![SlrStatement::Send(SlrSendStmt::sim_with_value( + &key, value, + ))]); + } else if channel.args.len() == 1 { + // Just a key, no value + let key_expr = channel.args[0].value(); + let key = if let crate::ast::Expr::StringLit(s) = key_expr { + s.value.clone() + } else { + return Err(SlrError::UnsupportedExpression); + }; + return Ok(vec![SlrStatement::Send(SlrSendStmt::sim(&key))]); + } + } + + // For other commands like @emit.sim.noise_enable(), @emit.sim.noise_disable() + // the command name becomes the key + let key = channel.command.clone(); + if let Some(arg) = channel.args.first() { + let value = self.convert_expression(arg.value())?; + Ok(vec![SlrStatement::Send(SlrSendStmt::sim_with_value( + &key, value, + ))]) + } else { + Ok(vec![SlrStatement::Send(SlrSendStmt::sim(&key))]) + } + } + } + } + + /// Convert @emit.hw.* channel expressions. + fn convert_hw_channel( + &mut self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { + // hw channel is opposite of sim: active for hardware, elided for simulator + match self.sim_mode { + SimMode::Emit => { + // Simulator target - elide hw messages + Ok(vec![]) + } + SimMode::Barrier | SimMode::Elide => { + // Hardware target - emit hw messages + let key = channel.command.clone(); + if let Some(arg) = channel.args.first() { + let value = self.convert_expression(arg.value())?; + Ok(vec![SlrStatement::Send( + SlrSendStmt::new("hw", &key).with_value(value), + )]) + } else { + Ok(vec![SlrStatement::Send(SlrSendStmt::new("hw", &key))]) + } + } + } + } + + /// Convert custom channel expressions. + fn convert_custom_channel( + &mut self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { + // Custom channels emit as SendStmt with channel name + // They act as barriers (sticky) + let key = channel.command.clone(); + if let Some(arg) = channel.args.first() { + let value = self.convert_expression(arg.value())?; + Ok(vec![SlrStatement::Send( + SlrSendStmt::new(&channel.channel, &key).with_value(value), + )]) + } else { + Ok(vec![SlrStatement::Send(SlrSendStmt::new( + &channel.channel, + &key, + ))]) + } + } + + /// Convert a gate expression: h q[0], rx(0.123) q[0], h {q[0], q[1]} + fn convert_gate_expr_with_attrs( + &mut self, + gate: &crate::ast::GateExpr, + attrs: std::collections::BTreeMap, + ) -> SlrResult> { + use crate::ast::GateKind; + + // Handle PZ (prepare) specially + if gate.kind == GateKind::PZ { + return self.convert_prepare_from_gate_expr(gate); + } + + // Map GateKind to SLR gate name + let gate_name: &'static str = match gate.kind { + GateKind::X => "X", + GateKind::Y => "Y", + GateKind::Z => "Z", + GateKind::H => "H", + GateKind::T => "T", + GateKind::Tdg => "Tdg", + GateKind::SX => "SX", + GateKind::SY => "SY", + GateKind::SZ => "SZ", + GateKind::SXdg => "SXdg", + GateKind::SYdg => "SYdg", + GateKind::SZdg => "SZdg", + GateKind::RX => "RX", + GateKind::RY => "RY", + GateKind::RZ => "RZ", + GateKind::CX => "CX", + GateKind::CY => "CY", + GateKind::CZ => "CZ", + GateKind::CH => "CH", + GateKind::SWAP => "SWAP", + GateKind::ISWAP => "ISWAP", + GateKind::SXX => "SXX", + GateKind::SYY => "SYY", + GateKind::SZZ => "SZZ", + GateKind::SXXdg => "SXXdg", + GateKind::SYYdg => "SYYdg", + GateKind::SZZdg => "SZZdg", + GateKind::RZZ => "RZZ", + GateKind::CCX => "CCX", + GateKind::F => "F", + GateKind::Fdg => "Fdg", + GateKind::F4 => "F4", + GateKind::F4dg => "F4dg", + GateKind::PZ => unreachable!(), // Handled above + }; + + // Convert parameters + let params: Vec = gate + .params + .iter() + .map(|p| self.convert_expression(p)) + .collect::>>()?; + + // Convert target based on its type + match &gate.target { + // Single qubit: q[0] + Expr::Index(_) => { + let slot_ref = self.extract_slot_ref(&gate.target)?; + Ok(vec![SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![slot_ref]) + .with_params(params) + .with_attrs(attrs), + )]) + } + // Batch with set: {q[0], q[1]} + Expr::Set(set) => { + let arity = gate_kind_arity(&gate.kind); + let mut statements = Vec::new(); + for elem in &set.elements { + match (arity, elem) { + // Single qubit in batch (arity 1 only) + (1, Expr::Index(_)) => { + let slot_ref = self.extract_slot_ref(elem)?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![slot_ref]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + // Tuple for two-qubit gates: (q[0], q[1]) + (2, Expr::Tuple(tuple)) if tuple.elements.len() == 2 => { + let control = self.extract_slot_ref(&tuple.elements[0])?; + let target = self.extract_slot_ref(&tuple.elements[1])?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![control, target]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + // Wrong arity: single qubit for 2-qubit gate or vice versa + (expected, _) => { + return Err(SlrError::WrongArgumentCount { + gate: gate_name.to_string(), + expected, + got: if matches!(elem, Expr::Index(_)) { 1 } else { 2 }, + }); + } + } + } + Ok(statements) + } + // Two-qubit gate with tuple: (q[0], q[1]) + Expr::Tuple(tuple) if tuple.elements.len() == 2 => { + let control = self.extract_slot_ref(&tuple.elements[0])?; + let target = self.extract_slot_ref(&tuple.elements[1])?; + Ok(vec![SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![control, target]) + .with_params(params) + .with_attrs(attrs), + )]) + } + // Batch with bracket array: [q[0], q[1]] + Expr::BracketArray(arr) => { + let arity = gate_kind_arity(&gate.kind); + let mut statements = Vec::new(); + for elem in &arr.elements { + match (arity, elem) { + (1, Expr::Index(_)) => { + let slot_ref = self.extract_slot_ref(elem)?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![slot_ref]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + (2, Expr::Tuple(tuple)) if tuple.elements.len() == 2 => { + let control = self.extract_slot_ref(&tuple.elements[0])?; + let target = self.extract_slot_ref(&tuple.elements[1])?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![control, target]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + (expected, _) => { + return Err(SlrError::WrongArgumentCount { + gate: gate_name.to_string(), + expected, + got: if matches!(elem, Expr::Index(_)) { 1 } else { 2 }, + }); + } + } + } + Ok(statements) + } + // Allocator reference for "apply to all": h q (single-qubit gates only) + Expr::Ident(ident) => { + // Get allocator info to determine capacity + let alloc = self.allocators.get(&ident.name).ok_or_else(|| { + SlrError::UndefinedAllocator { + name: ident.name.clone(), + } + })?; + let capacity = alloc.capacity; + let alloc_name = alloc.name.clone(); + + // Apply gate to all qubits in allocator + let mut statements = Vec::new(); + for i in 0..capacity { + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![SlrSlotRef::new(&alloc_name, i)]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + Ok(statements) + } + _ => Err(SlrError::UnsupportedExpression), + } + } + + /// Convert pz (prepare) gate expression to prepare operation + fn convert_prepare_from_gate_expr( + &mut self, + gate: &crate::ast::GateExpr, + ) -> SlrResult> { + match &gate.target { + // pz q - prepare all qubits in allocator + Expr::Ident(ident) => Ok(vec![SlrStatement::Prepare(SlrPrepareOp::all(&ident.name))]), + // pz q[0] - prepare single qubit + Expr::Index(_) => { + let slot_ref = self.extract_slot_ref(&gate.target)?; + Ok(vec![SlrStatement::Prepare(SlrPrepareOp::slots( + slot_ref.allocator, + vec![slot_ref.index], + ))]) + } + // pz {q[0], q[1]} - prepare batch (set) + Expr::Set(set) => { + let mut slots_by_alloc: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for elem in &set.elements { + let slot_ref = self.extract_slot_ref(elem)?; + slots_by_alloc + .entry(slot_ref.allocator) + .or_default() + .push(slot_ref.index); + } + let mut statements = Vec::new(); + for (alloc, slots) in slots_by_alloc { + statements.push(SlrStatement::Prepare(SlrPrepareOp::slots(alloc, slots))); + } + Ok(statements) + } + // pz [q[0], q[1]] - prepare batch (array) + Expr::BracketArray(arr) => { + let mut slots_by_alloc: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for elem in &arr.elements { + let slot_ref = self.extract_slot_ref(elem)?; + slots_by_alloc + .entry(slot_ref.allocator) + .or_default() + .push(slot_ref.index); + } + let mut statements = Vec::new(); + for (alloc, slots) in slots_by_alloc { + statements.push(SlrStatement::Prepare(SlrPrepareOp::slots(alloc, slots))); + } + Ok(statements) + } + _ => Err(SlrError::UnsupportedExpression), + } + } + + /// Convert a batch apply expression: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } + fn convert_batch_apply_with_attrs( + &mut self, + batch: &crate::ast::BatchApplyExpr, + attrs: std::collections::BTreeMap, + ) -> SlrResult> { + // Extract gate info and params from the operation + let (gate_name, params) = match &batch.operation { + Expr::Ident(ident) => (ident.name.clone(), Vec::new()), + Expr::Call(call) => { + if let Expr::Ident(ident) = &call.callee { + let mut params = Vec::new(); + for arg in &call.args { + params.push(self.convert_expression(arg)?); + } + (ident.name.clone(), params) + } else { + return Err(SlrError::UnsupportedExpression); + } + } + _ => return Err(SlrError::UnsupportedExpression), + }; + + // Get gate info + let Some(gate_info) = get_gate_info(&gate_name) else { + return Err(SlrError::UnsupportedExpression); + }; + + // Convert batch targets to statements + self.convert_batch_gate_with_attrs(gate_info, &batch.targets, params, attrs) + } + + /// Convert AST attributes to SLR attributes. + fn convert_attributes( + &self, + attrs: &[crate::ast::Attribute], + ) -> std::collections::BTreeMap { + let mut result = std::collections::BTreeMap::new(); + for attr in attrs { + let value = match &attr.value { + Some(crate::ast::AttributeValue::Bool(b)) => SlrAttributeValue::Bool(*b), + Some(crate::ast::AttributeValue::Int(i)) => SlrAttributeValue::Int(*i), + Some(crate::ast::AttributeValue::Float(f)) => SlrAttributeValue::Float(*f), + Some(crate::ast::AttributeValue::String(s)) => SlrAttributeValue::String(s.clone()), + Some(crate::ast::AttributeValue::Ident(s)) => SlrAttributeValue::String(s.clone()), + None => SlrAttributeValue::Bool(true), // Flag attributes default to true + }; + result.insert(attr.name.clone(), value); + } + result + } + + fn convert_call(&mut self, call: &CallExpr) -> SlrResult> { + self.convert_call_with_attrs(call, std::collections::BTreeMap::new()) + } + + fn convert_call_with_attrs( + &mut self, + call: &CallExpr, + attrs: std::collections::BTreeMap, + ) -> SlrResult> { + let name = self.extract_call_name(&call.callee)?; + + // Check for special operations (lowercase only) + match name.as_str() { + // mz uses new syntax: mz(T) targets - handled via Expr::Measure + // Old mz(T, target) call syntax is no longer supported + // pz = prepare +Z eigenstate (reset) + "pz" => return self.convert_reset(call), + "barrier" => return self.convert_barrier(call), + _ => {} + } + + // Check for external function calls + if self.extern_fns.contains(&name) { + let args: Result, SlrError> = call + .args + .iter() + .map(|arg| self.convert_expression(arg)) + .collect(); + return Ok(vec![SlrStatement::ExternCall(SlrExternCall::new( + name, args?, None, // Result variable set later during assignment handling + ))]); + } + + // Check for gate calls + let Some(gate_info) = get_gate_info(&name) else { + return Ok(vec![]); + }; + + // For parameterized gates: angle comes first, then qubits + // rz(1.57, q[0]) or rz(1.57, [q[0], q[1]) + let (params, qubit_args) = if gate_info.parameterized { + if call.args.is_empty() { + return Err(SlrError::WrongArgumentCount { + gate: name, + expected: gate_info.arity + 1, + got: 0, + }); + } + let param = self.convert_expression(&call.args[0])?; + (vec![param], &call.args[1..]) + } else { + (Vec::new(), &call.args[..]) + }; + + // Check if qubit argument is a Set or BracketArray (batch operation) + if !qubit_args.is_empty() { + if let Expr::Set(set_expr) = &qubit_args[0] { + return self.convert_batch_gate_with_attrs( + gate_info, + &set_expr.elements, + params, + attrs, + ); + } + if let Expr::BracketArray(arr_expr) = &qubit_args[0] { + return self.convert_batch_gate_with_attrs( + gate_info, + &arr_expr.elements, + params, + attrs, + ); + } + } + + // Standard single-target gate call + // Validate argument count + if qubit_args.len() != gate_info.arity { + return Err(SlrError::WrongArgumentCount { + gate: name, + expected: if gate_info.parameterized { + gate_info.arity + 1 + } else { + gate_info.arity + }, + got: call.args.len(), + }); + } + + // Extract qubit targets + let mut targets = Vec::with_capacity(gate_info.arity); + for arg in qubit_args.iter().take(gate_info.arity) { + let slot_ref = self.extract_slot_ref(arg)?; + targets.push(slot_ref); + } + + Ok(vec![SlrStatement::Gate( + SlrGateOp::new(gate_info.name, targets) + .with_params(params) + .with_attrs(attrs), + )]) + } + + /// Convert a batch gate operation into multiple SLR gates. + fn convert_batch_gate( + &mut self, + gate_info: GateInfo, + elements: &[Expr], + params: Vec, + ) -> SlrResult> { + self.convert_batch_gate_with_attrs( + gate_info, + elements, + params, + std::collections::BTreeMap::new(), + ) + } + + /// Convert a batch gate operation with attributes into multiple SLR gates. + /// Works with both Set and BracketArray expressions. + fn convert_batch_gate_with_attrs( + &mut self, + gate_info: GateInfo, + elements: &[Expr], + params: Vec, + attrs: std::collections::BTreeMap, + ) -> SlrResult> { + let mut statements = Vec::new(); + + if gate_info.arity == 1 { + // Single-qubit gate: each element is a qubit + for elem in elements { + let slot_ref = self.extract_slot_ref(elem)?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_info.name, vec![slot_ref]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + } else if gate_info.arity == 2 { + // Two-qubit gate: each element is a tuple (control, target) + for elem in elements { + if let Expr::Tuple(tuple) = elem { + if tuple.elements.len() == 2 { + let control = self.extract_slot_ref(&tuple.elements[0])?; + let target = self.extract_slot_ref(&tuple.elements[1])?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_info.name, vec![control, target]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } else { + return Err(SlrError::UnsupportedExpression); + } + } else { + return Err(SlrError::UnsupportedExpression); + } + } + } else { + return Err(SlrError::UnsupportedExpression); + } + + Ok(statements) + } + + fn convert_measure(&mut self, call: &CallExpr) -> SlrResult> { + // Typed measurement: mz(type, target) where: + // - type is u1, u8, u64, []u1, []u8, []u64 + // - target is q[0] or &[q[0], q[1], ...] + + // Check for typed measurement syntax (2 args: type + target) + if call.args.len() == 2 { + return self.convert_typed_measure(call); + } + + // Legacy: measure(q[0]) or measure(q[0], q[1], ...) + let mut targets = Vec::new(); + let mut results = Vec::new(); + + for arg in &call.args { + let slot_ref = self.extract_slot_ref(arg)?; + + // Auto-generate result register if needed + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + + Ok(vec![SlrStatement::Measure(SlrMeasureOp::new( + targets, results, + ))]) + } + + /// Convert a typed measurement call: mz(type, target) + fn convert_typed_measure(&mut self, call: &CallExpr) -> SlrResult> { + // First argument is the type + let result_type = self.extract_measurement_result_type(&call.args[0]); + // Second argument is the target(s) + let target_arg = &call.args[1]; + + let mut targets = Vec::new(); + let mut results = Vec::new(); + + // Extract targets from second argument + match target_arg { + // Single qubit: q[0] + Expr::Index(_) => { + let slot_ref = self.extract_slot_ref(target_arg)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + + // Address-of array: &[q[0], q[1], ...] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op { + if let Expr::BracketArray(arr) = &unary.operand { + for elem in &arr.elements { + let slot_ref = self.extract_slot_ref(elem)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + } else { + return Err(SlrError::UnsupportedExpression); + } + } else { + return Err(SlrError::UnsupportedExpression); + } + } + + _ => return Err(SlrError::UnsupportedExpression), + } + + Ok(vec![SlrStatement::Measure(SlrMeasureOp::with_result_type( + targets, + results, + &result_type, + ))]) + } + + /// Extract measurement result type from a type expression. + fn extract_measurement_result_type(&self, expr: &Expr) -> String { + match expr { + Expr::Ident(ident) => ident.name.clone(), + // For slice types []u1, []u8, []u64 - extract the element type + Expr::SlotRef(slot_ref) => slot_ref.allocator.clone(), + _ => "u1".to_string(), // Default to u1 + } + } + + /// Extract measurement result type from a TypeExpr (for new mz(T) target syntax). + fn extract_measurement_result_type_from_type_expr( + &self, + type_expr: &crate::ast::TypeExpr, + ) -> String { + use crate::ast::{PrimitiveType, TypeExpr}; + match type_expr { + TypeExpr::Primitive(prim) => match prim { + PrimitiveType::UInt { bits } => format!("u{bits}"), + PrimitiveType::IInt { bits } => format!("i{bits}"), + _ => "u1".to_string(), // Default for other types + }, + TypeExpr::Named(path) => { + // For named types, return the path as string + path.segments.join("::") + } + TypeExpr::Array(arr) => { + // For array types like []u1, extract the element type + self.extract_measurement_result_type_from_type_expr(&arr.element) + } + _ => "u1".to_string(), // Default to u1 + } + } + + fn convert_reset(&mut self, call: &CallExpr) -> SlrResult> { + // reset(q[0]) or reset with allocator + if call.args.is_empty() { + return Ok(vec![]); + } + + let slot_ref = self.extract_slot_ref(&call.args[0])?; + Ok(vec![SlrStatement::Prepare(SlrPrepareOp::slots( + slot_ref.allocator, + vec![slot_ref.index], + ))]) + } + + fn convert_barrier(&mut self, call: &CallExpr) -> SlrResult> { + let mut allocators = Vec::new(); + for arg in &call.args { + if let Expr::Ident(ident) = arg { + allocators.push(ident.name.clone()); + } + } + Ok(vec![SlrStatement::Barrier(SlrBarrierOp::new(allocators))]) + } + + // Conversion functions for AST quantum statement types + + fn convert_gate_op(&mut self, gate_op: &crate::ast::GateOp) -> SlrResult { + use crate::ast::GateKind; + + // Map AST GateKind to SLR gate name + let gate_name: &'static str = match gate_op.kind { + GateKind::X => "X", + GateKind::Y => "Y", + GateKind::Z => "Z", + GateKind::H => "H", + GateKind::T => "T", + GateKind::Tdg => "Tdg", + GateKind::SX => "SX", + GateKind::SY => "SY", + GateKind::SZ => "SZ", + GateKind::SXdg => "SXdg", + GateKind::SYdg => "SYdg", + GateKind::SZdg => "SZdg", + GateKind::RX => "RX", + GateKind::RY => "RY", + GateKind::RZ => "RZ", + GateKind::CX => "CX", + GateKind::CY => "CY", + GateKind::CZ => "CZ", + GateKind::CH => "CH", + GateKind::SWAP => "SWAP", + GateKind::ISWAP => "ISWAP", + GateKind::SXX => "SXX", + GateKind::SYY => "SYY", + GateKind::SZZ => "SZZ", + GateKind::SXXdg => "SXXdg", + GateKind::SYYdg => "SYYdg", + GateKind::SZZdg => "SZZdg", + GateKind::RZZ => "RZZ", + GateKind::CCX => "CCX", + GateKind::F => "F", + GateKind::Fdg => "Fdg", + GateKind::F4 => "F4", + GateKind::F4dg => "F4dg", + GateKind::PZ => "PZ", // Prepare operation, handled specially + }; + + // Convert targets + let targets: Vec = gate_op + .targets + .iter() + .map(|slot_ref| { + let index = self.extract_const_index(&slot_ref.index).unwrap_or(0); + SlrSlotRef::new(&slot_ref.allocator, index) + }) + .collect(); + + // Convert parameters + let params: Vec = gate_op + .params + .iter() + .map(|expr| self.convert_expression(expr)) + .collect::>>()?; + + Ok(SlrStatement::Gate( + SlrGateOp::new(gate_name, targets).with_params(params), + )) + } + + fn convert_prepare_op( + &mut self, + prepare_op: &crate::ast::PrepareOp, + ) -> SlrResult { + if let Some(ref slots) = prepare_op.slots { + let slot_indices: Vec = slots.iter().map(|&s| s as usize).collect(); + Ok(SlrStatement::Prepare(SlrPrepareOp::slots( + &prepare_op.allocator, + slot_indices, + ))) + } else { + Ok(SlrStatement::Prepare(SlrPrepareOp::all( + &prepare_op.allocator, + ))) + } + } + + fn convert_measure_op( + &mut self, + measure_op: &crate::ast::MeasureOp, + ) -> SlrResult { + let mut targets = Vec::new(); + let mut results = Vec::new(); + + for slot_ref in &measure_op.targets { + let index = self.extract_const_index(&slot_ref.index).unwrap_or(0); + targets.push(SlrSlotRef::new(&slot_ref.allocator, index)); + + // Auto-generate result register if needed + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + } + + Ok(SlrStatement::Measure(SlrMeasureOp::new(targets, results))) + } + + fn convert_barrier_op( + &mut self, + barrier_op: &crate::ast::BarrierOp, + ) -> SlrResult { + Ok(SlrStatement::Barrier(SlrBarrierOp::new( + barrier_op.allocators.clone(), + ))) + } + + fn convert_if(&mut self, if_stmt: &IfStmt) -> SlrResult { + let condition = self.convert_expression(&if_stmt.condition)?; + let then_body = self.convert_block(&if_stmt.then_body)?; + + let else_body = if let Some(ref else_branch) = if_stmt.else_body { + self.convert_else_branch(else_branch)? + } else { + Vec::new() + }; + + Ok(SlrStatement::If( + SlrIfStmt::new(condition, then_body).with_else(else_body), + )) + } + + fn convert_else_branch(&mut self, branch: &ElseBranch) -> SlrResult> { + match branch { + ElseBranch::Else(block) => self.convert_block(block), + ElseBranch::ElseIf(if_stmt) => { + let condition = self.convert_expression(&if_stmt.condition)?; + let then_body = self.convert_block(&if_stmt.then_body)?; + let else_body = if let Some(ref else_branch) = if_stmt.else_body { + self.convert_else_branch(else_branch)? + } else { + Vec::new() + }; + Ok(vec![SlrStatement::If( + SlrIfStmt::new(condition, then_body).with_else(else_body), + )]) + } + } + } + + fn convert_tick(&mut self, tick_stmt: &crate::ast::TickStmt) -> SlrResult { + // Convert all statements within the tick + let mut body = Vec::new(); + for stmt in &tick_stmt.body { + body.extend(self.convert_stmt(stmt)?); + } + + // Create tick statement with optional label + let mut slr_tick = SlrTickStmt::new(body); + if let Some(ref label) = tick_stmt.label { + slr_tick = slr_tick.with_label(label.clone()); + } + + // Convert attributes + if !tick_stmt.attrs.is_empty() { + let mut attrs = std::collections::BTreeMap::new(); + for attr in &tick_stmt.attrs { + let value = match &attr.value { + Some(crate::ast::AttributeValue::Bool(b)) => SlrAttributeValue::Bool(*b), + Some(crate::ast::AttributeValue::Int(i)) => SlrAttributeValue::Int(*i), + Some(crate::ast::AttributeValue::Float(f)) => SlrAttributeValue::Float(*f), + Some(crate::ast::AttributeValue::String(s)) => { + SlrAttributeValue::String(s.clone()) + } + Some(crate::ast::AttributeValue::Ident(s)) => { + SlrAttributeValue::String(s.clone()) + } + None => SlrAttributeValue::Bool(true), // Flag attributes default to true + }; + attrs.insert(attr.name.clone(), value); + } + slr_tick = slr_tick.with_attrs(attrs); + } + + Ok(SlrStatement::Tick(slr_tick)) + } + + fn convert_for(&mut self, for_stmt: &ForStmt) -> SlrResult { + // For SLR, convert bounded for loops to repeat statements + // For unbounded, use ForStmt + if let Some(count) = self.try_extract_repeat_count(for_stmt) { + let body = self.convert_block(&for_stmt.body)?; + return Ok(SlrStatement::Repeat(SlrRepeatStmt::new(count, body))); + } + + // General for loop - extract from ForRange + let (start, end) = match &for_stmt.range { + ForRange::Range { start, end } => ( + self.convert_expression(start)?, + self.convert_expression(end)?, + ), + ForRange::Collection(expr) => { + // For collection iteration, use the expression as both start and end placeholder + let expr_converted = self.convert_expression(expr)?; + (expr_converted.clone(), expr_converted) + } + }; + + // Get the iteration variable from captures (first capture is the loop variable) + let variable = for_stmt + .captures + .first() + .cloned() + .unwrap_or_else(|| "_".to_string()); + + let body = self.convert_block(&for_stmt.body)?; + Ok(SlrStatement::For(SlrForStmt::new( + variable, start, end, body, + ))) + } + + fn try_extract_repeat_count(&self, for_stmt: &ForStmt) -> Option { + // Check for patterns like: for _ in 0..10 { } + if let ForRange::Range { start, end } = &for_stmt.range + && let Expr::IntLit(start_lit) = start + && start_lit.value == 0 + && let Expr::IntLit(end_lit) = end + { + return Some(end_lit.value as usize); + } + None + } + + fn convert_expression(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::IntLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::int( + lit.value as i64, + ))), + Expr::FloatLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::float(lit.value))), + Expr::BoolLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::bool(lit.value))), + Expr::Ident(ident) => { + // Check for built-in angle constants + match ident.name.as_str() { + "pi" | "PI" => { + return Ok(SlrExpression::Literal(SlrLiteralExpr::float( + std::f64::consts::PI, + ))); + } + "tau" | "TAU" => { + return Ok(SlrExpression::Literal(SlrLiteralExpr::float( + std::f64::consts::TAU, + ))); + } + "e" | "E" => { + return Ok(SlrExpression::Literal(SlrLiteralExpr::float( + std::f64::consts::E, + ))); + } + _ => {} + } + Ok(SlrExpression::Var(SlrVarExpr::new(&ident.name))) + } + Expr::Binary(binary) => { + let op = binary_op_to_slr(&binary.op); + let left = self.convert_expression(&binary.left)?; + let right = self.convert_expression(&binary.right)?; + Ok(SlrExpression::Binary(SlrBinaryExpr::new(op, left, right))) + } + Expr::Index(index) => { + // Check if this is a bit reference (register[index]) + let name = self.extract_identifier(&index.object)?; + if self.registers.contains_key(&name) { + let idx = self.extract_integer(&index.index)?; + return Ok(SlrExpression::Bit(SlrBitExpr::new(name, idx))); + } + Err(SlrError::UnsupportedExpression) + } + Expr::AngleLit(angle) => { + use crate::ast::AngleUnit; + + // For radians, try to recognize common pi-based patterns for exact conversion + if let AngleUnit::Rad = angle.unit + && let Some(exact_turns) = recognize_exact_radian_pattern(&angle.value) + { + return Ok(SlrExpression::Literal(SlrLiteralExpr::angle(exact_turns))); + } + + // Fall back to floating-point evaluation + use crate::comptime::ComptimeEvaluator; + let mut eval = ComptimeEvaluator::new(); + let value = eval + .eval_expr(&angle.value) + .map_err(|_| SlrError::InvalidAngle)?; + let numeric = match value { + crate::comptime::ComptimeValue::Float(f) => f, + crate::comptime::ComptimeValue::Int(i) => i as f64, + crate::comptime::ComptimeValue::Uint(u) => u as f64, + _ => return Err(SlrError::InvalidAngle), + }; + // Convert to turns (the native unit for angles) + let turns = angle.unit.to_turns(numeric); + // Output as angle literal with type information + Ok(SlrExpression::Literal(SlrLiteralExpr::angle(turns))) + } + Expr::TypeAscription(asc) => { + // For type ascription, evaluate the expression + // The type information is used for semantic checking, but the value is what matters for codegen + self.convert_expression(&asc.value) + } + Expr::StringLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::string(&lit.value))), + Expr::FString(fstr) => { + use crate::ast::FStringPart; + let parts = fstr + .parts + .iter() + .map(|part| match part { + FStringPart::Text(text) => Ok(SlrFStringPart::Text { + value: text.clone(), + }), + FStringPart::Expr { expr, format } => Ok(SlrFStringPart::Expr { + value: Box::new(self.convert_expression(expr)?), + format: format.clone(), + }), + }) + .collect::>>()?; + Ok(SlrExpression::FString(SlrFStringExpr::new(parts))) + } + _ => Err(SlrError::UnsupportedExpression), + } + } + + // ========================================================================= + // Extraction Helpers + // ========================================================================= + + fn try_extract_allocator(&self, expr: &Expr) -> Option { + if let Expr::Call(call) = expr { + let name = self.extract_call_name(&call.callee).ok()?; + if name == "qalloc" && call.args.len() == 1 { + return self.extract_integer(&call.args[0]).ok(); + } + } + None + } + + fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { + if let Expr::Call(call) = expr + && let Expr::Field(field) = &call.callee + && field.field == "child" + && call.args.len() == 1 + { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } + None + } + + fn extract_call_name(&self, callee: &Expr) -> SlrResult { + match callee { + Expr::Ident(ident) => Ok(ident.name.clone()), + Expr::Field(field) => Ok(field.field.clone()), + _ => Err(SlrError::UnsupportedExpression), + } + } + + fn extract_identifier(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::Ident(ident) => Ok(ident.name.clone()), + _ => Err(SlrError::UnsupportedExpression), + } + } + + fn extract_slot_ref(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::Index(index) => self.extract_slot_from_index(index), + _ => Err(SlrError::UnsupportedExpression), + } + } + + fn extract_slot_from_index(&self, index: &IndexExpr) -> SlrResult { + let allocator = self.extract_identifier(&index.object)?; + let idx = self.extract_integer(&index.index)?; + + // Validate allocator exists + let alloc = + self.allocators + .get(&allocator) + .ok_or_else(|| SlrError::UndefinedAllocator { + name: allocator.clone(), + })?; + + // Validate index bounds + if idx >= alloc.capacity { + return Err(SlrError::QubitIndexOutOfBounds { + allocator: allocator.clone(), + index: idx, + capacity: alloc.capacity, + }); + } + + Ok(SlrSlotRef::new(allocator, idx)) + } + + fn extract_integer(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value as usize), + _ => Err(SlrError::UnsupportedExpression), + } + } + + fn extract_const_index(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(lit) => Some(lit.value as usize), + _ => None, + } + } + + /// Convert an ExternFnDecl to SLR-AST representation. + fn convert_extern_fn(&self, extern_fn: &crate::ast::ExternFnDecl) -> SlrResult { + let params: Vec = extern_fn + .params + .iter() + .map(|p| SlrExternParam { + name: p.name.clone(), + ctype: self.convert_type_to_ctype(&p.ty), + }) + .collect(); + + let return_type = extern_fn + .return_type + .as_ref() + .map(|t| self.convert_type_to_ctype(t)); + + Ok(SlrExternDecl::new( + extern_fn.name.clone(), + extern_fn.library.clone(), + extern_fn.calling_convention.clone(), + params, + return_type, + )) + } + + /// Convert a Zlup type expression to a C-compatible type. + fn convert_type_to_ctype(&self, ty: &crate::ast::TypeExpr) -> SlrCType { + use crate::ast::{PrimitiveType, TypeExpr}; + + match ty { + // Primitive types (parsed from u8, u32, i32, f32, etc.) + TypeExpr::Primitive(prim) => match prim { + PrimitiveType::UInt { bits } => SlrCType::Int { + bits: *bits as u8, + signed: false, + }, + PrimitiveType::IInt { bits } => SlrCType::Int { + bits: *bits as u8, + signed: true, + }, + PrimitiveType::Usize => SlrCType::Int { + bits: 64, + signed: false, + }, // Assume 64-bit + PrimitiveType::Isize => SlrCType::Int { + bits: 64, + signed: true, + }, + PrimitiveType::F16 => SlrCType::Float { bits: 16 }, + PrimitiveType::F32 => SlrCType::Float { bits: 32 }, + PrimitiveType::F64 => SlrCType::Float { bits: 64 }, + PrimitiveType::F128 => SlrCType::Float { bits: 128 }, + PrimitiveType::A64 => SlrCType::Angle { bits: 64 }, // PECOS Angle64 + PrimitiveType::Bool => SlrCType::Int { + bits: 8, + signed: false, + }, + }, + // Named types (for fallback or custom types) + TypeExpr::Named(path) => { + let name = path.segments.join("::"); + match name.as_str() { + "u8" => SlrCType::Int { + bits: 8, + signed: false, + }, + "u16" => SlrCType::Int { + bits: 16, + signed: false, + }, + "u32" => SlrCType::Int { + bits: 32, + signed: false, + }, + "u64" => SlrCType::Int { + bits: 64, + signed: false, + }, + "usize" => SlrCType::Int { + bits: 64, + signed: false, + }, // Assume 64-bit + "i8" => SlrCType::Int { + bits: 8, + signed: true, + }, + "i16" => SlrCType::Int { + bits: 16, + signed: true, + }, + "i32" => SlrCType::Int { + bits: 32, + signed: true, + }, + "i64" => SlrCType::Int { + bits: 64, + signed: true, + }, + "isize" => SlrCType::Int { + bits: 64, + signed: true, + }, + "f32" => SlrCType::Float { bits: 32 }, + "f64" => SlrCType::Float { bits: 64 }, + "bool" => SlrCType::Int { + bits: 8, + signed: false, + }, + "unit" | "void" => SlrCType::Void, + _ => SlrCType::Opaque { name }, + } + } + // Pointer types: *T, [*]T, [*:0]T + TypeExpr::Pointer(ptr) => { + let element = self.convert_type_to_ctype(&ptr.pointee); + SlrCType::Pointer { + element: Box::new(element), + is_const: ptr.is_const, + } + } + // Array types used as pointers in C + TypeExpr::Array(arr) => { + let element = self.convert_type_to_ctype(&arr.element); + SlrCType::Pointer { + element: Box::new(element), + is_const: false, + } + } + // Unit type + TypeExpr::Unit => SlrCType::Void, + // Default to opaque for unknown types + _ => SlrCType::Opaque { + name: format!("{:?}", ty), + }, + } + } +} + +impl Default for SlrCodegen { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Angle Precision Helpers +// ============================================================================= + +/// Recognize common pi-based radian expressions and return exact turn fractions. +/// +/// This preserves precision by pattern-matching the AST before floating-point evaluation. +/// For example: +/// - `pi / 2` → 0.25 turns (exact) +/// - `pi / 4` → 0.125 turns (exact) +/// - `3 * pi / 4` → 0.375 turns (exact) +fn recognize_exact_radian_pattern(expr: &Expr) -> Option { + // Pattern: pi (just pi = 1/2 turn) + if is_pi_reference(expr) { + return Some(0.5); + } + + // Pattern: pi / N (pi divided by integer) + if let Expr::Binary(binary) = expr { + if binary.op == crate::ast::BinaryOp::Div + && is_pi_reference(&binary.left) + && let Some(n) = extract_integer_value(&binary.right) + && n > 0 + { + // pi / n radians = 1 / (2*n) turns + return Some(1.0 / (2.0 * n as f64)); + } + + // Pattern: N * pi / M or (N * pi) / M + if binary.op == crate::ast::BinaryOp::Div + && let Some((num, denom)) = extract_pi_fraction(&binary.left, &binary.right) + { + // (num * pi) / denom radians = num / (2 * denom) turns + return Some(num as f64 / (2.0 * denom as f64)); + } + + // Pattern: pi * N / M (reordered) + if binary.op == crate::ast::BinaryOp::Mul { + // Check for pi * (N / M) - less common but possible + if is_pi_reference(&binary.left) + && let Expr::Binary(inner) = &binary.right + && inner.op == crate::ast::BinaryOp::Div + && let (Some(num), Some(denom)) = ( + extract_integer_value(&inner.left), + extract_integer_value(&inner.right), + ) + && denom > 0 + { + return Some(num as f64 / (2.0 * denom as f64)); + } + } + } + + None +} + +/// Check if an expression is a reference to pi (std.f64.pi, pi, PI, etc.) +fn is_pi_reference(expr: &Expr) -> bool { + match expr { + Expr::Ident(ident) => { + matches!(ident.name.as_str(), "pi" | "PI") + } + Expr::Field(field) => { + // Check for std.f64.pi or similar + field.field == "pi" || field.field == "PI" + } + _ => false, + } +} + +/// Extract an integer value from an expression (literal or simple expression) +fn extract_integer_value(expr: &Expr) -> Option { + match expr { + Expr::IntLit(lit) => Some(lit.value as i64), + // Handle negative integers + Expr::Unary(unary) if unary.op == crate::ast::UnaryOp::Neg => { + extract_integer_value(&unary.operand).map(|v| -v) + } + _ => None, + } +} + +/// Extract numerator and denominator from expressions like N * pi / M +fn extract_pi_fraction(left: &Expr, right: &Expr) -> Option<(i64, i64)> { + // Check if left is N * pi + if let Expr::Binary(mul) = left + && mul.op == crate::ast::BinaryOp::Mul + { + // N * pi + if let Some(n) = extract_integer_value(&mul.left) + && is_pi_reference(&mul.right) + && let Some(m) = extract_integer_value(right) + && m > 0 + { + return Some((n, m)); + } + // pi * N + if is_pi_reference(&mul.left) + && let Some(n) = extract_integer_value(&mul.right) + && let Some(m) = extract_integer_value(right) + && m > 0 + { + return Some((n, m)); + } + } + None +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + fn compile_to_slr(source: &str) -> SlrResult { + let program = parse(source).expect("parse failed"); + let mut codegen = SlrCodegen::new(); + codegen.compile(&program) + } + + fn to_json(source: &str) -> String { + let program = parse(source).expect("parse failed"); + let mut codegen = SlrCodegen::new(); + let slr = codegen.compile(&program).expect("compile failed"); + codegen.to_json(&slr).expect("json failed") + } + + #[test] + fn test_empty_program() { + let slr = compile_to_slr("").unwrap(); + assert_eq!(slr.name, "main"); + assert!(slr.body.is_empty()); + } + + #[test] + fn test_single_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "H"); // Output remains uppercase + assert_eq!(gate.targets.len(), 1); + assert_eq!(gate.targets[0].allocator, "q"); + assert_eq!(gate.targets[0].index, 0); + } else { + panic!("Expected gate operation"); + } + } + + #[test] + fn test_bell_state() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 2); + + // First gate: h (output is uppercase H) + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "H"); + } else { + panic!("Expected H gate"); + } + + // Second gate: cx (output is uppercase CX) + if let SlrStatement::Gate(gate) = &slr.body[1] { + assert_eq!(gate.gate, "CX"); + assert_eq!(gate.targets.len(), 2); + } else { + panic!("Expected CX gate"); + } + } + + #[test] + fn test_rotation_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + rz(1.57, q[0]); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "RZ"); + assert_eq!(gate.params.len(), 1); + } else { + panic!("Expected rz gate"); + } + } + + #[test] + fn test_angle_precision_preservation() { + // Test that pi/N rad patterns are converted to exact turn fractions + let source = r#" + pi: f64 = 3.14159265358979323846; + pub fn main() -> unit { + mut q := qalloc(2); + rz(pi/4 rad) q[0]; // Should be exactly 0.125 turns + rz(pi/2 rad) q[1]; // Should be exactly 0.25 turns + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 2); + + // Check first gate: pi/4 rad = 0.125 turns + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "RZ"); + if let SlrExpression::Literal(lit) = &gate.params[0] { + if let SlrLiteralValue::Angle(turns) = &lit.value { + assert!( + (*turns - 0.125).abs() < 1e-15, + "pi/4 rad should be exactly 0.125 turns, got {}", + turns + ); + } else { + panic!("Expected angle literal"); + } + } + } + + // Check second gate: pi/2 rad = 0.25 turns + if let SlrStatement::Gate(gate) = &slr.body[1] { + assert_eq!(gate.gate, "RZ"); + if let SlrExpression::Literal(lit) = &gate.params[0] { + if let SlrLiteralValue::Angle(turns) = &lit.value { + assert!( + (*turns - 0.25).abs() < 1e-15, + "pi/2 rad should be exactly 0.25 turns, got {}", + turns + ); + } else { + panic!("Expected angle literal"); + } + } + } + } + + #[test] + fn test_allocator_decl() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert!(slr.allocator.is_some()); + + let alloc = slr.allocator.unwrap(); + assert_eq!(alloc.name, "q"); + assert_eq!(alloc.capacity, 4); + } + + #[test] + fn test_child_allocator() { + let source = r#" + pub fn main() -> unit { + mut base := qalloc(4); + mut q := base.child(2); + h q[0]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + + // Should have both allocators in declarations + let alloc_count = slr + .declarations + .iter() + .filter(|d| matches!(d, SlrDeclaration::Allocator(_))) + .count(); + assert_eq!(alloc_count, 2); + } + + #[test] + fn test_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + } + "#; + + let json = to_json(source); + + // Verify it's valid JSON + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + // Check structure + assert_eq!(value["type"], "Program"); + assert_eq!(value["name"], "main"); + assert!(value["allocator"].is_object()); + assert!(value["body"].is_array()); + assert_eq!(value["body"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_if_statement() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + mut x := 1; + if (x == 1) { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + + // Find the if statement + let has_if = slr.body.iter().any(|s| matches!(s, SlrStatement::If(_))); + assert!(has_if, "Expected if statement in body"); + } + + #[test] + fn test_wrong_argument_count() { + // CX requires pairs of qubits - passing individual qubits in a batch is wrong + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + cx {q[0], q[1]}; + } + "#; + + let result = compile_to_slr(source); + assert!(matches!(result, Err(SlrError::WrongArgumentCount { .. }))); + } + + #[test] + fn test_qubit_out_of_bounds() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[5]; + } + "#; + + let result = compile_to_slr(source); + assert!(matches!( + result, + Err(SlrError::QubitIndexOutOfBounds { .. }) + )); + } + + #[test] + fn test_tick_block() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick { + h q[0]; + h q[1]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert!(tick.label.is_none()); + assert_eq!(tick.body.len(), 2); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_block_with_label() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick syndrome_round { + h q[0]; + cx (q[0], q[1]); + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert_eq!(tick.label.as_ref().unwrap(), "syndrome_round"); + assert_eq!(tick.body.len(), 2); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_block_with_string_label() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick "layer_1" { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert_eq!(tick.label.as_ref().unwrap(), "layer_1"); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_nested_tick_blocks() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick outer { + tick inner1 { + h q[0]; + } + tick inner2 { + x(q[1]); + } + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(outer) = &slr.body[0] { + assert_eq!(outer.label.as_ref().unwrap(), "outer"); + assert_eq!(outer.body.len(), 2); + + // Check nested ticks + if let SlrStatement::Tick(inner1) = &outer.body[0] { + assert_eq!(inner1.label.as_ref().unwrap(), "inner1"); + } else { + panic!("Expected nested tick"); + } + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick layer1 { + h q[0]; + h q[1]; + } + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + // Check tick structure in JSON + let tick = &value["body"][0]; + assert_eq!(tick["type"], "TickStmt"); + assert_eq!(tick["label"], "layer1"); + assert!(tick["body"].is_array()); + assert_eq!(tick["body"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_tick_with_inline_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick @attrs({round: 0, kind: "syndrome"}) syndrome_round { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert_eq!(tick.label.as_ref().unwrap(), "syndrome_round"); + assert_eq!(tick.attrs.len(), 2); + assert!(matches!( + tick.attrs.get("round"), + Some(SlrAttributeValue::Int(0)) + )); + assert!( + matches!(tick.attrs.get("kind"), Some(SlrAttributeValue::String(s)) if s == "syndrome") + ); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_with_prefix_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attr(noisy, true) + tick { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert!(tick.label.is_none()); + assert_eq!(tick.attrs.len(), 1); + assert!(matches!( + tick.attrs.get("noisy"), + Some(SlrAttributeValue::Bool(true)) + )); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_with_mixed_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attrs({error_rate: 0.001, round: 5}) + tick layer { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert_eq!(tick.label.as_ref().unwrap(), "layer"); + assert_eq!(tick.attrs.len(), 2); + assert!(matches!( + tick.attrs.get("round"), + Some(SlrAttributeValue::Int(5)) + )); + // Check float attribute + if let Some(SlrAttributeValue::Float(f)) = tick.attrs.get("error_rate") { + assert!((*f - 0.001).abs() < 0.0001); + } else { + panic!("Expected float attribute"); + } + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_attributes_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick @attrs({round: 0, kind: "syndrome"}) syndrome { + h q[0]; + } + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let tick = &value["body"][0]; + assert_eq!(tick["type"], "TickStmt"); + assert_eq!(tick["label"], "syndrome"); + assert_eq!(tick["attrs"]["round"], 0); + assert_eq!(tick["attrs"]["kind"], "syndrome"); + } + + // ========================================================================= + // Gate Attribute Tests + // ========================================================================= + + #[test] + fn test_gate_with_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attr(syndrome, "X") + cx (q[0], q[1]); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "CX"); + assert_eq!(gate.attrs.len(), 1); + assert!( + matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "X") + ); + } else { + panic!("Expected gate statement"); + } + } + + #[test] + fn test_gate_with_multiple_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attrs({syndrome: "Z", layer: 1}) + h q[0]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "H"); + assert_eq!(gate.attrs.len(), 2); + assert!( + matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "Z") + ); + assert!(matches!( + gate.attrs.get("layer"), + Some(SlrAttributeValue::Int(1)) + )); + } else { + panic!("Expected gate statement"); + } + } + + #[test] + fn test_batch_gate_with_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + @attr(syndrome, "X") + h([q[0], q[1]]); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + // Batch gate expands to 2 gates, each should have the attribute + assert_eq!(slr.body.len(), 2); + + for stmt in &slr.body { + if let SlrStatement::Gate(gate) = stmt { + assert_eq!(gate.gate, "H"); + assert_eq!(gate.attrs.len(), 1); + assert!( + matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "X") + ); + } else { + panic!("Expected gate statement"); + } + } + } + + #[test] + fn test_gate_attributes_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attrs({syndrome: "X", ancilla: true}) + cx (q[0], q[1]); + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let gate = &value["body"][0]; + assert_eq!(gate["type"], "GateOp"); + assert_eq!(gate["gate"], "CX"); + assert_eq!(gate["attrs"]["syndrome"], "X"); + assert_eq!(gate["attrs"]["ancilla"], true); + } + + #[test] + fn test_gate_without_attributes_no_attrs_field() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let gate = &value["body"][0]; + assert_eq!(gate["type"], "GateOp"); + // attrs field should be absent (skip_serializing_if = is_empty) + assert!(gate.get("attrs").is_none()); + } + + // ========================================================================= + // Typed Measurement Tests + // ========================================================================= + + #[test] + fn test_typed_measurement_single_qubit() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + r := mz(u1) q[0]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Measure(measure) = &slr.body[0] { + assert_eq!(measure.targets.len(), 1); + assert_eq!(measure.targets[0].allocator, "q"); + assert_eq!(measure.targets[0].index, 0); + assert_eq!(measure.results.len(), 1); + } else { + panic!("Expected measure operation"); + } + } + + #[test] + fn test_typed_measurement_array() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + results := mz(u1) [q[0], q[1], q[2]]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Measure(measure) = &slr.body[0] { + assert_eq!(measure.targets.len(), 3); + assert_eq!(measure.targets[0].allocator, "q"); + assert_eq!(measure.targets[0].index, 0); + assert_eq!(measure.targets[1].index, 1); + assert_eq!(measure.targets[2].index, 2); + assert_eq!(measure.results.len(), 3); + } else { + panic!("Expected measure operation"); + } + } + + #[test] + fn test_typed_measurement_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + r := mz(u1) q[0]; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let measure = &value["body"][0]; + assert_eq!(measure["type"], "MeasureOp"); + assert!(measure["targets"].is_array()); + assert_eq!(measure["targets"].as_array().unwrap().len(), 1); + assert!(measure["results"].is_array()); + assert_eq!(measure["results"].as_array().unwrap().len(), 1); + assert_eq!(measure["result_type"], "u1"); + } + + #[test] + fn test_measurement_result_type_u8() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + r := mz(u8) q[0]; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let measure = &value["body"][0]; + assert_eq!(measure["type"], "MeasureOp"); + assert_eq!(measure["result_type"], "u8"); + } + + #[test] + fn test_measurement_result_type_u64() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + r := mz(u64) q[0]; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let measure = &value["body"][0]; + assert_eq!(measure["type"], "MeasureOp"); + assert_eq!(measure["result_type"], "u64"); + } + + #[test] + fn test_extern_fn_declaration() { + let source = r#" + extern "C" fn decode(data: [*]u8, len: usize) -> i32; + + pub fn main() -> unit { + q := qalloc(1); + pz q; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + // Check that externs array exists and has one entry + let externs = &value["externs"]; + assert!(externs.is_array(), "externs should be an array"); + assert_eq!(externs.as_array().unwrap().len(), 1); + + // Check extern function properties + let extern_fn = &externs[0]; + assert_eq!(extern_fn["type"], "ExternDecl"); + assert_eq!(extern_fn["name"], "decode"); + assert_eq!(extern_fn["calling_convention"], "C"); + + // Check params + let params = &extern_fn["params"]; + assert_eq!(params.as_array().unwrap().len(), 2); + assert_eq!(params[0]["name"], "data"); + assert_eq!(params[1]["name"], "len"); + } + + #[test] + fn test_extern_fn_with_library() { + let source = r#" + @link("libdecoder") + extern "C" fn mwpm_decode(syndrome: [*]u8, n: u32) -> i32; + + pub fn main() -> unit { + q := qalloc(1); + pz q; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let extern_fn = &value["externs"][0]; + assert_eq!(extern_fn["name"], "mwpm_decode"); + assert_eq!(extern_fn["library"], "libdecoder"); + assert_eq!(extern_fn["calling_convention"], "C"); + + // Check params + let first_param = &extern_fn["params"][0]; + assert_eq!(first_param["name"], "syndrome"); + assert_eq!(first_param["ctype"]["kind"], "pointer"); + assert_eq!(first_param["ctype"]["element"]["kind"], "int"); + assert_eq!(first_param["ctype"]["element"]["bits"], 8); + } + + #[test] + fn test_extern_fn_rust_abi() { + let source = r#" + extern "Rust" fn pecos_simulate(circuit: *const u8) -> u64; + + pub fn main() -> unit { + q := qalloc(1); + pz q; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let extern_fn = &value["externs"][0]; + assert_eq!(extern_fn["name"], "pecos_simulate"); + assert_eq!(extern_fn["calling_convention"], "Rust"); + } + + #[test] + fn test_extern_fn_call() { + let source = r#" + extern "C" fn simple_decode(value: u32) -> i32; + + pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + + // Call external decoder with a simple literal + simple_decode(42); + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + // Check that extern declaration is present + assert_eq!(value["externs"][0]["name"], "simple_decode"); + + // Check that extern call is in the body + let body = &value["body"]; + let extern_call = body + .as_array() + .unwrap() + .iter() + .find(|stmt| stmt["type"] == "ExternCall"); + assert!(extern_call.is_some(), "Expected ExternCall in body"); + + let call = extern_call.unwrap(); + assert_eq!(call["function"], "simple_decode"); + assert_eq!(call["args"].as_array().unwrap().len(), 1); + // The argument should be a literal value + assert_eq!(call["args"][0]["value"], 42); + } +} diff --git a/exp/zlup/src/comptime.rs b/exp/zlup/src/comptime.rs new file mode 100644 index 000000000..98814632b --- /dev/null +++ b/exp/zlup/src/comptime.rs @@ -0,0 +1,3780 @@ +//! Compile-time evaluation for Zlup. +//! +//! This module provides compile-time evaluation of expressions, enabling: +//! - `comptime` blocks that execute at compile time +//! - `comptime` function parameters (generics) +//! - Compile-time type manipulation +//! - Constant folding and propagation +//! +//! ## Comptime Values +//! +//! Values that can exist at compile time: +//! - Integers (i64 for signed, u64 for unsigned) +//! - Floats (f64) +//! - Booleans +//! - Types (the `type` type) +//! - Arrays of comptime values +//! - Structs with comptime fields +//! +//! ## Example +//! +//! ```zlup +//! // Comptime block +//! N := comptime { +//! mut sum: u32 = 0; +//! for i in 0..10 { +//! sum += i; +//! } +//! sum +//! }; +//! +//! // Comptime function parameter (generic) +//! fn makeArray(comptime T: type, comptime N: usize) -> [N]T { +//! mut arr: [N]T = undefined; +//! return arr; +//! } +//! ``` + +use std::collections::BTreeMap; +use std::fmt; + +use crate::ast::{ + BinaryOp, Expr, FStringPart, FnDecl, ForRange, PrimitiveType, Stmt, TypeExpr, UnaryOp, +}; +use crate::rational::Rational; +use crate::semantic::{BitWidth, SemanticError, Type}; + +// ============================================================================= +// Type Information (for @typeInfo, @fieldNames, @enumFields builtins) +// ============================================================================= + +/// The kind of a type, returned by @typeInfo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TypeInfoKind { + /// Primitive types (bool, integers, floats) + Primitive, + /// Array type with fixed size + Array, + /// Slice type (dynamic size) + Slice, + /// Pointer type + Pointer, + /// Optional type (?T) + Optional, + /// Error union type (E!T) + ErrorUnion, + /// Struct type + Struct, + /// Enum type + Enum, + /// Union type + Union, + /// Error set type + ErrorSet, + /// Fault set type + FaultSet, + /// Function type + Function, + /// Tuple type + Tuple, + /// The type type (metatype) + Type, + /// Unit type + Unit, + /// Never type (bottom) + Never, + /// Quantum types (Qubit, Bit, Allocator) + Quantum, + /// Unknown/unresolved type + Unknown, +} + +impl TypeInfoKind { + /// Convert to a string representation for use in comptime values. + pub fn as_str(&self) -> &'static str { + match self { + TypeInfoKind::Primitive => "primitive", + TypeInfoKind::Array => "array", + TypeInfoKind::Slice => "slice", + TypeInfoKind::Pointer => "pointer", + TypeInfoKind::Optional => "optional", + TypeInfoKind::ErrorUnion => "error_union", + TypeInfoKind::Struct => "struct", + TypeInfoKind::Enum => "enum", + TypeInfoKind::Union => "union", + TypeInfoKind::ErrorSet => "error_set", + TypeInfoKind::FaultSet => "fault_set", + TypeInfoKind::Function => "function", + TypeInfoKind::Tuple => "tuple", + TypeInfoKind::Type => "type", + TypeInfoKind::Unit => "unit", + TypeInfoKind::Never => "never", + TypeInfoKind::Quantum => "quantum", + TypeInfoKind::Unknown => "unknown", + } + } +} + +/// A value known at compile time. +#[derive(Debug, Clone)] +pub enum ComptimeValue { + /// Signed integer value + Int(i64), + /// Unsigned integer value + Uint(u64), + /// Floating point value + Float(f64), + /// Rational number (exact fraction like 1/4) + Rational(Rational), + /// Boolean value + Bool(bool), + /// A type value (for `type` type) + Type(Type), + /// Null value (for optionals) + Null, + /// Undefined value + Undefined, + /// Unit value (the single value of the unit type) + Unit, + /// Array of comptime values + Array(Vec), + /// Struct with named fields + Struct { + name: String, + fields: BTreeMap, + }, + /// Slice reference (ptr + len) + Slice { data: Vec }, + /// String literal + String(String), + /// A comptime function (for generic type constructors) + Function(Box), +} + +impl PartialEq for ComptimeValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => a == b, + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => a == b, + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => a == b, + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => a == b, + (ComptimeValue::Bool(a), ComptimeValue::Bool(b)) => a == b, + (ComptimeValue::Type(a), ComptimeValue::Type(b)) => a == b, + (ComptimeValue::Null, ComptimeValue::Null) => true, + (ComptimeValue::Undefined, ComptimeValue::Undefined) => true, + (ComptimeValue::Unit, ComptimeValue::Unit) => true, + (ComptimeValue::Array(a), ComptimeValue::Array(b)) => a == b, + ( + ComptimeValue::Struct { + name: n1, + fields: f1, + }, + ComptimeValue::Struct { + name: n2, + fields: f2, + }, + ) => n1 == n2 && f1 == f2, + (ComptimeValue::Slice { data: d1 }, ComptimeValue::Slice { data: d2 }) => d1 == d2, + (ComptimeValue::String(a), ComptimeValue::String(b)) => a == b, + (ComptimeValue::Function(_), ComptimeValue::Function(_)) => false, // Functions not comparable + _ => false, + } + } +} + +impl fmt::Display for ComptimeValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ComptimeValue::Int(v) => write!(f, "{}", v), + ComptimeValue::Uint(v) => write!(f, "{}", v), + ComptimeValue::Float(v) => write!(f, "{}", v), + ComptimeValue::Rational(v) => write!(f, "{}", v), + ComptimeValue::Bool(v) => write!(f, "{}", v), + ComptimeValue::Type(t) => write!(f, "{}", t.display_name()), + ComptimeValue::Null => write!(f, "null"), + ComptimeValue::Undefined => write!(f, "undefined"), + ComptimeValue::Unit => write!(f, "unit"), + ComptimeValue::Array(arr) => { + write!(f, "[")?; + for (i, v) in arr.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", v)?; + } + write!(f, "]") + } + ComptimeValue::Struct { name, fields } => { + write!(f, "{} {{ ", name)?; + for (i, (k, v)) in fields.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, ".{} = {}", k, v)?; + } + write!(f, " }}") + } + ComptimeValue::Slice { data } => { + write!(f, "&[")?; + for (i, v) in data.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", v)?; + } + write!(f, "]") + } + ComptimeValue::String(s) => write!(f, "\"{}\"", s), + ComptimeValue::Function(func) => write!(f, "", func.name), + } + } +} + +impl ComptimeValue { + /// Get the type of this comptime value. + pub fn get_type(&self) -> Type { + match self { + ComptimeValue::Int(_) => Type::IInt { + bits: BitWidth::BITS_64, + }, + ComptimeValue::Uint(_) => Type::UInt { + bits: BitWidth::BITS_64, + }, + ComptimeValue::Float(_) => Type::F64, + ComptimeValue::Rational(_) => Type::F64, // Rationals coerce to f64 when needed + ComptimeValue::Bool(_) => Type::Bool, + ComptimeValue::Type(_) => Type::Type, + ComptimeValue::Null => Type::Optional { + inner: Box::new(Type::Unknown), + }, + ComptimeValue::Undefined => Type::Unknown, + ComptimeValue::Unit => Type::Unit, + ComptimeValue::Array(arr) => { + let elem_ty = arr.first().map(|v| v.get_type()).unwrap_or(Type::Unknown); + Type::Array { + element: Box::new(elem_ty), + size: Some(arr.len() as u64), + } + } + ComptimeValue::Struct { name, fields } => { + let field_types: Vec<(String, Type)> = fields + .iter() + .map(|(k, v)| (k.clone(), v.get_type())) + .collect(); + Type::Struct { + name: name.clone(), + fields: field_types, + } + } + ComptimeValue::Slice { data } => { + let elem_ty = data.first().map(|v| v.get_type()).unwrap_or(Type::Unknown); + Type::Slice { + element: Box::new(elem_ty), + } + } + ComptimeValue::String(_) => Type::Slice { + element: Box::new(Type::UInt { + bits: BitWidth::BITS_8, + }), + }, + ComptimeValue::Function(_) => Type::Type, // Comptime functions return types + } + } + + /// Try to convert to i64. + pub fn as_int(&self) -> Option { + match self { + ComptimeValue::Int(v) => Some(*v), + ComptimeValue::Uint(v) => Some(*v as i64), + _ => None, + } + } + + /// Try to convert to u64. + pub fn as_uint(&self) -> Option { + match self { + ComptimeValue::Int(v) => Some(*v as u64), + ComptimeValue::Uint(v) => Some(*v), + _ => None, + } + } + + /// Try to convert to f64. + pub fn as_float(&self) -> Option { + match self { + ComptimeValue::Float(v) => Some(*v), + ComptimeValue::Int(v) => Some(*v as f64), + ComptimeValue::Uint(v) => Some(*v as f64), + ComptimeValue::Rational(r) => Some(r.to_f64()), + _ => None, + } + } + + /// Try to get as Rational. + pub fn as_rational(&self) -> Option { + match self { + ComptimeValue::Rational(r) => Some(*r), + ComptimeValue::Int(v) => Some(Rational::from_int(*v)), + ComptimeValue::Uint(v) => Some(Rational::from_int(*v as i64)), + ComptimeValue::Float(v) => Rational::from_f64_common(*v), + _ => None, + } + } + + /// Try to convert to bool. + pub fn as_bool(&self) -> Option { + match self { + ComptimeValue::Bool(v) => Some(*v), + _ => None, + } + } + + /// Try to convert to usize. + pub fn to_usize(&self) -> Option { + match self { + ComptimeValue::Int(v) => (*v).try_into().ok(), + ComptimeValue::Uint(v) => (*v).try_into().ok(), + _ => None, + } + } + + /// Try to get as type. + pub fn as_type(&self) -> Option<&Type> { + match self { + ComptimeValue::Type(t) => Some(t), + _ => None, + } + } + + /// Check if this is a truthy value. + pub fn is_truthy(&self) -> bool { + match self { + ComptimeValue::Bool(v) => *v, + ComptimeValue::Int(v) => *v != 0, + ComptimeValue::Uint(v) => *v != 0, + ComptimeValue::Rational(r) => !r.is_zero(), + ComptimeValue::Null => false, + ComptimeValue::Undefined => false, + _ => true, + } + } +} + +/// Result of comptime evaluation. +pub type ComptimeResult = Result; + +/// Errors during comptime evaluation. +#[derive(Debug, Clone)] +pub struct ComptimeError { + pub message: String, +} + +impl fmt::Display for ComptimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ComptimeError {} + +impl From for SemanticError { + fn from(err: ComptimeError) -> Self { + SemanticError::ComptimeError { + message: err.message, + location: crate::ast::SourceLocation::default(), + } + } +} + +/// Comptime evaluation context. +/// +/// Tracks variables and their values during comptime evaluation. +#[derive(Debug, Clone, Default)] +pub struct ComptimeContext { + /// Variable bindings in the current scope. + scopes: Vec>, +} + +impl ComptimeContext { + /// Create a new comptime context. + pub fn new() -> Self { + Self { + scopes: vec![BTreeMap::new()], + } + } + + /// Push a new scope. + pub fn push_scope(&mut self) { + self.scopes.push(BTreeMap::new()); + } + + /// Pop the current scope. + pub fn pop_scope(&mut self) { + if self.scopes.len() > 1 { + self.scopes.pop(); + } + } + + /// Define a variable in the current scope. + pub fn define(&mut self, name: &str, value: ComptimeValue) { + if let Some(scope) = self.scopes.last_mut() { + scope.insert(name.to_string(), value); + } + } + + /// Look up a variable. + pub fn lookup(&self, name: &str) -> Option<&ComptimeValue> { + for scope in self.scopes.iter().rev() { + if let Some(value) = scope.get(name) { + return Some(value); + } + } + None + } + + /// Update a variable's value. + pub fn update(&mut self, name: &str, value: ComptimeValue) -> bool { + for scope in self.scopes.iter_mut().rev() { + if scope.contains_key(name) { + scope.insert(name.to_string(), value); + return true; + } + } + false + } +} + +/// Comptime evaluator. +/// +/// Maximum recursion depth for comptime evaluation to prevent stack overflow. +const MAX_COMPTIME_DEPTH: usize = 256; + +/// Evaluates expressions at compile time, producing `ComptimeValue` results. +#[derive(Debug)] +pub struct ComptimeEvaluator { + /// Evaluation context. + pub context: ComptimeContext, + /// Current recursion depth for detecting infinite recursion. + depth: usize, + /// Memoization cache for comptime function calls. + /// Key: (function_name, serialized_args), Value: cached result + memo_cache: BTreeMap<(String, String), ComptimeValue>, +} + +impl Default for ComptimeEvaluator { + fn default() -> Self { + Self::new() + } +} + +impl ComptimeEvaluator { + /// Create a new comptime evaluator. + pub fn new() -> Self { + Self { + context: ComptimeContext::new(), + depth: 0, + memo_cache: BTreeMap::new(), + } + } + + /// Serialize comptime values to a string key for memoization cache. + fn serialize_args_for_cache(args: &[ComptimeValue]) -> String { + args.iter() + .map(|v| match v { + ComptimeValue::Int(n) => format!("i{}", n), + ComptimeValue::Uint(n) => format!("u{}", n), + ComptimeValue::Float(f) => format!("f{}", f), + ComptimeValue::Rational(r) => format!("r{}/{}", r.numerator(), r.denominator()), + ComptimeValue::Bool(b) => format!("b{}", b), + ComptimeValue::String(s) => format!("s{}", s), + ComptimeValue::Type(t) => format!("t{}", Self::serialize_type_for_cache(t)), + ComptimeValue::Array(arr) => { + format!("a[{}]", Self::serialize_args_for_cache(arr)) + } + ComptimeValue::Slice { data } => { + format!("sl[{}]", Self::serialize_args_for_cache(data)) + } + ComptimeValue::Struct { name, fields } => { + let field_strs: Vec<_> = fields + .iter() + .map(|(k, v)| { + format!( + "{}:{}", + k, + Self::serialize_args_for_cache(std::slice::from_ref(v)) + ) + }) + .collect(); + format!("st{}[{}]", name, field_strs.join(";")) + } + ComptimeValue::Function(f) => format!("fn{}", f.name), + ComptimeValue::Null => "null".to_string(), + ComptimeValue::Undefined => "undef".to_string(), + ComptimeValue::Unit => "unit".to_string(), + }) + .collect::>() + .join(",") + } + + /// Serialize a Type to a unique string for cache keys. + /// Unlike display_name(), this includes full structural information for anonymous types. + fn serialize_type_for_cache(ty: &Type) -> String { + match ty { + Type::Struct { name, fields } => { + // For structs (especially anonymous ones), include full field information + let field_strs: Vec<_> = fields + .iter() + .map(|(field_name, field_ty)| { + format!( + "{}:{}", + field_name, + Self::serialize_type_for_cache(field_ty) + ) + }) + .collect(); + format!("struct{}[{}]", name, field_strs.join(";")) + } + Type::Array { element, size } => { + format!( + "[{}]{}", + size.unwrap_or(0), + Self::serialize_type_for_cache(element) + ) + } + Type::Slice { element } => { + format!("[]{}", Self::serialize_type_for_cache(element)) + } + Type::Pointer { + pointee, is_const, .. + } => { + let prefix = if *is_const { "*const" } else { "*" }; + format!("{}{}", prefix, Self::serialize_type_for_cache(pointee)) + } + Type::Optional { inner } => { + format!("?{}", Self::serialize_type_for_cache(inner)) + } + Type::Tuple { elements } => { + let elem_strs: Vec<_> = elements + .iter() + .map(Self::serialize_type_for_cache) + .collect(); + format!("({})", elem_strs.join(",")) + } + // For other types, display_name() is sufficient + _ => ty.display_name(), + } + } + + /// Check and increment recursion depth, returning error if too deep. + fn enter_eval(&mut self) -> ComptimeResult<()> { + self.depth += 1; + if self.depth > MAX_COMPTIME_DEPTH { + Err(ComptimeError { + message: format!( + "comptime evaluation exceeded maximum recursion depth of {}", + MAX_COMPTIME_DEPTH + ), + }) + } else { + Ok(()) + } + } + + /// Decrement recursion depth when exiting evaluation. + fn exit_eval(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// Validate that a float result is finite (not Inf or NaN). + /// Returns an error if the value is not a valid finite number. + fn validate_float(value: f64) -> ComptimeResult { + if value.is_finite() { + Ok(ComptimeValue::Float(value)) + } else if value.is_nan() { + Err(ComptimeError { + message: "floating-point operation resulted in NaN".to_string(), + }) + } else { + Err(ComptimeError { + message: "floating-point operation resulted in infinity".to_string(), + }) + } + } + + /// Resolve a built-in type name to a Type. + /// Returns None if the name is not a built-in type. + fn resolve_builtin_type(&self, name: &str) -> Option { + // Special cases first + match name { + "bool" => return Some(Type::Bool), + "usize" => return Some(Type::Usize), + "isize" => return Some(Type::Isize), + "f16" => return Some(Type::F16), + "f32" => return Some(Type::F32), + "f64" => return Some(Type::F64), + "f128" => return Some(Type::F128), + "a64" => return Some(Type::A64), + "type" => return Some(Type::Type), + "unit" => return Some(Type::Unit), + _ => {} + } + + // Arbitrary-width integers: u or i + // Valid bit widths are 1-128 + if let Some(bits_str) = name.strip_prefix('u') { + if let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) + { + return Some(Type::UInt { bits: bw }); + } + } else if let Some(bits_str) = name.strip_prefix('i') + && let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) + { + return Some(Type::IInt { bits: bw }); + } + + None + } + + /// Resolve a TypeExpr to a Type at comptime. + fn resolve_type_expr(&mut self, type_expr: &TypeExpr) -> ComptimeResult { + match type_expr { + TypeExpr::Primitive(prim) => Ok(match prim { + PrimitiveType::Bool => Type::Bool, + PrimitiveType::UInt { bits } => Type::UInt { + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64), + }, + PrimitiveType::IInt { bits } => Type::IInt { + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64), + }, + PrimitiveType::Usize => Type::Usize, + PrimitiveType::Isize => Type::Isize, + PrimitiveType::F16 => Type::F16, + PrimitiveType::F32 => Type::F32, + PrimitiveType::F64 => Type::F64, + PrimitiveType::F128 => Type::F128, + PrimitiveType::A64 => Type::A64, + }), + TypeExpr::Qubit => Ok(Type::Qubit), + TypeExpr::Bit => Ok(Type::Bit), + TypeExpr::QAlloc(_) => Ok(Type::Allocator { capacity: None }), + TypeExpr::Array(array) => { + let element = self.resolve_type_expr(&array.element)?; + let size = if let Some(size_expr) = &array.size { + self.eval_expr(size_expr)?.to_usize().map(|n| n as u64) + } else { + None + }; + Ok(Type::Array { + element: Box::new(element), + size, + }) + } + TypeExpr::Pointer(ptr) => { + let pointee = self.resolve_type_expr(&ptr.pointee)?; + Ok(Type::Pointer { + pointee: Box::new(pointee), + is_const: ptr.is_const, + is_many: ptr.is_many, + }) + } + TypeExpr::Optional(inner) => { + let inner_ty = self.resolve_type_expr(inner)?; + Ok(Type::Optional { + inner: Box::new(inner_ty), + }) + } + TypeExpr::Named(path) => { + // Try to resolve as built-in type first + let full_name = path.segments.join("::"); + if let Some(ty) = self.resolve_builtin_type(&full_name) { + return Ok(ty); + } + // Try to look up in context (for comptime-defined types) + if let Some(ComptimeValue::Type(ty)) = self.context.lookup(&full_name) { + return Ok(ty.clone()); + } + // For single-segment names, try direct lookup + if path.segments.len() == 1 + && let Some(ComptimeValue::Type(ty)) = self.context.lookup(&path.segments[0]) + { + return Ok(ty.clone()); + } + // Unresolved named type - return Unknown (will be resolved by semantic analyzer) + Ok(Type::Unknown) + } + TypeExpr::Type => Ok(Type::Type), + TypeExpr::Unit => Ok(Type::Unit), + TypeExpr::AnyType => Ok(Type::Unknown), // anytype resolves to unknown at comptime + TypeExpr::Tuple(types) => { + let mut resolved = Vec::new(); + for ty in types { + resolved.push(self.resolve_type_expr(ty)?); + } + Ok(Type::Tuple { elements: resolved }) + } + TypeExpr::Set(inner) => { + let inner_ty = self.resolve_type_expr(inner)?; + Ok(Type::Set { + element: Box::new(inner_ty), + }) + } + _ => Err(ComptimeError { + message: format!( + "type expression not yet supported at comptime: {:?}", + type_expr + ), + }), + } + } + + /// Evaluate a binary operation on comptime values. + pub fn eval_binary_op( + &self, + op: BinaryOp, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match op { + // Arithmetic operations + BinaryOp::Add => self.eval_add(left, right), + BinaryOp::Sub => self.eval_sub(left, right), + BinaryOp::Mul => self.eval_mul(left, right), + BinaryOp::Div => self.eval_div(left, right), + BinaryOp::Mod => self.eval_mod(left, right), + + // Comparison operations + BinaryOp::Eq => self.eval_eq(left, right), + BinaryOp::Ne => self.eval_ne(left, right), + BinaryOp::Lt => self.eval_lt(left, right), + BinaryOp::Le => self.eval_le(left, right), + BinaryOp::Gt => self.eval_gt(left, right), + BinaryOp::Ge => self.eval_ge(left, right), + + // Logical operations + BinaryOp::And => self.eval_and(left, right), + BinaryOp::Or => self.eval_or(left, right), + + // Bitwise operations + BinaryOp::BitAnd => self.eval_bit_and(left, right), + BinaryOp::BitOr => self.eval_bit_or(left, right), + BinaryOp::BitXor => self.eval_bit_xor(left, right), + BinaryOp::Shl => self.eval_shl(left, right), + BinaryOp::Shr => self.eval_shr(left, right), + + // Optional operations + BinaryOp::Orelse => self.eval_orelse(left, right), + + // Set membership (not evaluable at comptime) + BinaryOp::In | BinaryOp::NotIn => Err(ComptimeError { + message: "set membership not supported at comptime".to_string(), + }), + + // Error handling (not evaluable at comptime) + BinaryOp::Catch => Err(ComptimeError { + message: "catch expression not supported at comptime".to_string(), + }), + } + } + + /// Evaluate a unary operation. + pub fn eval_unary_op( + &self, + op: UnaryOp, + operand: &ComptimeValue, + ) -> ComptimeResult { + match op { + UnaryOp::Neg => match operand { + ComptimeValue::Int(v) => Ok(ComptimeValue::Int(-v)), + ComptimeValue::Float(v) => Ok(ComptimeValue::Float(-v)), + ComptimeValue::Rational(r) => Ok(ComptimeValue::Rational(-*r)), + _ => Err(ComptimeError { + message: format!("cannot negate {}", operand), + }), + }, + UnaryOp::Not => match operand { + ComptimeValue::Bool(v) => Ok(ComptimeValue::Bool(!v)), + _ => Err(ComptimeError { + message: format!("cannot apply ! to {}", operand), + }), + }, + UnaryOp::BitNot => match operand { + ComptimeValue::Int(v) => Ok(ComptimeValue::Int(!v)), + ComptimeValue::Uint(v) => Ok(ComptimeValue::Uint(!v)), + _ => Err(ComptimeError { + message: format!("cannot apply ~ to {}", operand), + }), + }, + UnaryOp::Deref => Err(ComptimeError { + message: "cannot dereference at comptime".to_string(), + }), + UnaryOp::AddrOf => Err(ComptimeError { + message: "cannot take address at comptime".to_string(), + }), + UnaryOp::OptionalUnwrap => match operand { + ComptimeValue::Null => Err(ComptimeError { + message: "unwrapped null value with .?".to_string(), + }), + other => Ok(other.clone()), + }, + UnaryOp::ErrorUnwrap => match operand { + ComptimeValue::Null => Err(ComptimeError { + message: "unwrapped error value with .!".to_string(), + }), + other => Ok(other.clone()), + }, + UnaryOp::Try => Err(ComptimeError { + message: "try expression not supported at comptime".to_string(), + }), + } + } + + // Arithmetic operations + + fn eval_add( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => a + .checked_add(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in addition".to_string(), + }), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => a + .checked_add(*b) + .map(ComptimeValue::Uint) + .ok_or_else(|| ComptimeError { + message: "unsigned integer overflow in addition".to_string(), + }), + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => a + .checked_add(*b as i64) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in addition".to_string(), + }), + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => (*a as i64) + .checked_add(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in addition".to_string(), + }), + // Rational arithmetic + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(*a + *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Rational(*a + Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a) + *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => { + Ok(ComptimeValue::Rational(*a + Rational::from_int(*b as i64))) + } + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) + *b)) + } + // Float arithmetic - validate results are finite + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a + b), + (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a + *b as f64), + (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 + b), + // Rational with float promotes to float + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { + Self::validate_float(a.to_f64() + b) + } + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { + Self::validate_float(a + b.to_f64()) + } + _ => Err(ComptimeError { + message: format!("cannot add {} and {}", left, right), + }), + } + } + + fn eval_sub( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => a + .checked_sub(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in subtraction".to_string(), + }), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => a + .checked_sub(*b) + .map(ComptimeValue::Uint) + .ok_or_else(|| ComptimeError { + message: "unsigned integer underflow in subtraction".to_string(), + }), + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => a + .checked_sub(*b as i64) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in subtraction".to_string(), + }), + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => (*a as i64) + .checked_sub(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in subtraction".to_string(), + }), + // Rational arithmetic + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(*a - *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Rational(*a - Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a) - *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => { + Ok(ComptimeValue::Rational(*a - Rational::from_int(*b as i64))) + } + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) - *b)) + } + // Float arithmetic - validate results are finite + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a - b), + (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a - *b as f64), + (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 - b), + // Rational with float promotes to float + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { + Self::validate_float(a.to_f64() - b) + } + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { + Self::validate_float(a - b.to_f64()) + } + _ => Err(ComptimeError { + message: format!("cannot subtract {} and {}", left, right), + }), + } + } + + fn eval_mul( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => a + .checked_mul(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in multiplication".to_string(), + }), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => a + .checked_mul(*b) + .map(ComptimeValue::Uint) + .ok_or_else(|| ComptimeError { + message: "unsigned integer overflow in multiplication".to_string(), + }), + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => a + .checked_mul(*b as i64) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in multiplication".to_string(), + }), + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => (*a as i64) + .checked_mul(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { + message: "integer overflow in multiplication".to_string(), + }), + // Rational arithmetic + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(*a * *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Rational(*a * Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a) * *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => { + Ok(ComptimeValue::Rational(*a * Rational::from_int(*b as i64))) + } + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) * *b)) + } + // Float arithmetic - validate results are finite + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a * b), + (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a * *b as f64), + (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 * b), + // Rational with float promotes to float + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { + Self::validate_float(a.to_f64() * b) + } + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { + Self::validate_float(a * b.to_f64()) + } + _ => Err(ComptimeError { + message: format!("cannot multiply {} and {}", left, right), + }), + } + } + + fn eval_div( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else if a % b == 0 { + // Exact division - return integer + Ok(ComptimeValue::Int(a / b)) + } else { + // Non-exact division - return Rational for exact fraction representation + // This prevents subtle bugs like `1/4 turns` being 0 + Ok(ComptimeValue::Rational(Rational::new(*a, *b))) + } + } + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else if a % b == 0 { + // Exact division - return unsigned integer + Ok(ComptimeValue::Uint(a / b)) + } else { + // Non-exact division - return Rational + Ok(ComptimeValue::Rational(Rational::new(*a as i64, *b as i64))) + } + } + // Rational arithmetic + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + if b.is_zero() { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Rational(*a / *b)) + } + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Rational(*a / Rational::from_int(*b))) + } + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + if b.is_zero() { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Rational(Rational::from_int(*a) / *b)) + } + } + // Float arithmetic - check for division by zero + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => { + if *b == 0.0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Self::validate_float(a / b) + } + } + (ComptimeValue::Int(a), ComptimeValue::Float(b)) => { + if *b == 0.0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Self::validate_float(*a as f64 / b) + } + } + (ComptimeValue::Float(a), ComptimeValue::Int(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Self::validate_float(a / *b as f64) + } + } + (ComptimeValue::Uint(a), ComptimeValue::Float(b)) => { + if *b == 0.0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Self::validate_float(*a as f64 / b) + } + } + (ComptimeValue::Float(a), ComptimeValue::Uint(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Self::validate_float(a / *b as f64) + } + } + // Rational with float promotes to float + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { + if *b == 0.0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Self::validate_float(a.to_f64() / b) + } + } + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { + if b.is_zero() { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Self::validate_float(a / b.to_f64()) + } + } + _ => Err(ComptimeError { + message: format!("cannot divide {} and {}", left, right), + }), + } + } + + fn eval_mod( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "modulo by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Int(a % b)) + } + } + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "modulo by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Uint(a % b)) + } + } + _ => Err(ComptimeError { + message: format!("cannot modulo {} and {}", left, right), + }), + } + } + + // Comparison operations + + fn eval_eq( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + Ok(ComptimeValue::Bool(left == right)) + } + + fn eval_ne( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + Ok(ComptimeValue::Bool(left != right)) + } + + fn eval_lt( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a < b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a < b)), + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a < b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(a < b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Bool(*a < Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(Rational::from_int(*a) < *b)) + } + _ => Err(ComptimeError { + message: format!("cannot compare {} < {}", left, right), + }), + } + } + + fn eval_le( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a <= b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a <= b)), + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a <= b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(a <= b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Bool(*a <= Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(Rational::from_int(*a) <= *b)) + } + _ => Err(ComptimeError { + message: format!("cannot compare {} <= {}", left, right), + }), + } + } + + fn eval_gt( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a > b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a > b)), + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a > b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(a > b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Bool(*a > Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(Rational::from_int(*a) > *b)) + } + _ => Err(ComptimeError { + message: format!("cannot compare {} > {}", left, right), + }), + } + } + + fn eval_ge( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a >= b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a >= b)), + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a >= b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(a >= b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Bool(*a >= Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(Rational::from_int(*a) >= *b)) + } + _ => Err(ComptimeError { + message: format!("cannot compare {} >= {}", left, right), + }), + } + } + + // Logical operations + + fn eval_and( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Bool(a), ComptimeValue::Bool(b)) => Ok(ComptimeValue::Bool(*a && *b)), + _ => Err(ComptimeError { + message: format!("cannot apply 'and' to {} and {}", left, right), + }), + } + } + + fn eval_or( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Bool(a), ComptimeValue::Bool(b)) => Ok(ComptimeValue::Bool(*a || *b)), + _ => Err(ComptimeError { + message: format!("cannot apply 'or' to {} and {}", left, right), + }), + } + } + + // Bitwise operations + + fn eval_bit_and( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a & b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a & b)), + _ => Err(ComptimeError { + message: format!("cannot apply & to {} and {}", left, right), + }), + } + } + + fn eval_bit_or( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a | b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a | b)), + _ => Err(ComptimeError { + message: format!("cannot apply | to {} and {}", left, right), + }), + } + } + + fn eval_bit_xor( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a ^ b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a ^ b)), + _ => Err(ComptimeError { + message: format!("cannot apply ^ to {} and {}", left, right), + }), + } + } + + fn eval_shl( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + let shift = right.as_uint().ok_or_else(|| ComptimeError { + message: "shift amount must be unsigned integer".to_string(), + })?; + + // Validate shift amount is within bounds (max 63 for 64-bit integers) + if shift >= 64 { + return Err(ComptimeError { + message: format!( + "shift amount {} is too large (max 63 for 64-bit integers)", + shift + ), + }); + } + let shift = shift as u32; + + match left { + ComptimeValue::Int(a) => Ok(ComptimeValue::Int(a << shift)), + ComptimeValue::Uint(a) => Ok(ComptimeValue::Uint(a << shift)), + _ => Err(ComptimeError { + message: format!("cannot shift {}", left), + }), + } + } + + fn eval_shr( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + let shift = right.as_uint().ok_or_else(|| ComptimeError { + message: "shift amount must be unsigned integer".to_string(), + })?; + + // Validate shift amount is within bounds (max 63 for 64-bit integers) + if shift >= 64 { + return Err(ComptimeError { + message: format!( + "shift amount {} is too large (max 63 for 64-bit integers)", + shift + ), + }); + } + let shift = shift as u32; + + match left { + ComptimeValue::Int(a) => Ok(ComptimeValue::Int(a >> shift)), + ComptimeValue::Uint(a) => Ok(ComptimeValue::Uint(a >> shift)), + _ => Err(ComptimeError { + message: format!("cannot shift {}", left), + }), + } + } + + // Optional operations + + fn eval_orelse( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match left { + ComptimeValue::Null => Ok(right.clone()), + other => Ok(other.clone()), + } + } + + // ========================================================================= + // Expression Evaluation + // ========================================================================= + + /// Evaluate an expression at compile time. + pub fn eval_expr(&mut self, expr: &Expr) -> ComptimeResult { + // Check recursion depth + self.enter_eval()?; + let result = self.eval_expr_inner(expr); + self.exit_eval(); + result + } + + /// Inner expression evaluation (after depth check). + fn eval_expr_inner(&mut self, expr: &Expr) -> ComptimeResult { + match expr { + // Literals + Expr::IntLit(lit) => { + // Convert i128 to i64, checking for overflow + let value = lit.value.try_into().map_err(|_| ComptimeError { + message: format!("integer literal {} too large for comptime i64", lit.value), + })?; + Ok(ComptimeValue::Int(value)) + } + Expr::FloatLit(lit) => Ok(ComptimeValue::Float(lit.value)), + Expr::AngleLit(angle) => { + // Evaluate the inner expression and convert to turns (native unit) + let val = self.eval_expr(&angle.value)?; + use crate::ast::AngleUnit; + + // For Rational values, preserve exact fraction when possible + if let ComptimeValue::Rational(r) = &val { + match angle.unit { + AngleUnit::Turns => { + // Already in turns - preserve exact rational + return Ok(val); + } + AngleUnit::Rad => { + // For rational radians, we can't preserve precision + // since radians involve pi (irrational) + // But we can try to detect if it represents n*pi/d + let radians = r.to_f64(); + if let Some(turns_rational) = Rational::radians_to_turns(radians) { + return Ok(ComptimeValue::Rational(turns_rational)); + } + let turns = radians / (2.0 * std::f64::consts::PI); + return Ok(ComptimeValue::Float(turns)); + } + } + } + + let numeric = match &val { + ComptimeValue::Float(f) => *f, + ComptimeValue::Int(i) => *i as f64, + ComptimeValue::Uint(u) => *u as f64, + _ => { + return Err(ComptimeError { + message: format!("angle value must be numeric, found {:?}", val), + }); + } + }; + + // For radian values, try to detect pi-multiples and preserve precision + if let AngleUnit::Rad = angle.unit + && let Some(turns_rational) = Rational::radians_to_turns(numeric) + { + return Ok(ComptimeValue::Rational(turns_rational)); + } + + // Fall back to float conversion + let turns = angle.unit.to_turns(numeric); + Ok(ComptimeValue::Float(turns)) + } + Expr::TypeAscription(asc) => { + // Evaluate the inner expression and convert to the specified type + let val = self.eval_expr(&asc.value)?; + // Convert based on target type + match asc.type_name.as_str() { + "f64" | "f32" | "f16" | "f128" => { + let f = match val { + ComptimeValue::Float(f) => f, + ComptimeValue::Int(i) => i as f64, + ComptimeValue::Uint(u) => u as f64, + ComptimeValue::Rational(r) => r.to_f64(), + _ => { + return Err(ComptimeError { + message: format!( + "cannot convert {:?} to {}", + val, asc.type_name + ), + }); + } + }; + Ok(ComptimeValue::Float(f)) + } + "a64" => { + // a64 expects a value in turns - preserve Rational if possible + match val { + ComptimeValue::Rational(r) => Ok(ComptimeValue::Rational(r)), + ComptimeValue::Float(f) => Ok(ComptimeValue::Float(f)), + ComptimeValue::Int(i) => { + Ok(ComptimeValue::Rational(Rational::from_int(i))) + } + ComptimeValue::Uint(u) => { + Ok(ComptimeValue::Rational(Rational::from_int(u as i64))) + } + _ => Err(ComptimeError { + message: format!("cannot convert {:?} to a64", val), + }), + } + } + t if t.starts_with('u') => { + let u = match val { + ComptimeValue::Uint(u) => u, + ComptimeValue::Int(i) if i >= 0 => i as u64, + ComptimeValue::Float(f) if f >= 0.0 => f as u64, + _ => { + return Err(ComptimeError { + message: format!( + "cannot convert {:?} to {}", + val, asc.type_name + ), + }); + } + }; + Ok(ComptimeValue::Uint(u)) + } + t if t.starts_with('i') => { + let i = match val { + ComptimeValue::Int(i) => i, + ComptimeValue::Uint(u) => u as i64, + ComptimeValue::Float(f) => f as i64, + _ => { + return Err(ComptimeError { + message: format!( + "cannot convert {:?} to {}", + val, asc.type_name + ), + }); + } + }; + Ok(ComptimeValue::Int(i)) + } + _ => Err(ComptimeError { + message: format!("unknown type suffix: {}", asc.type_name), + }), + } + } + Expr::BoolLit(lit) => Ok(ComptimeValue::Bool(lit.value)), + Expr::StringLit(lit) => Ok(ComptimeValue::String(lit.value.clone())), + Expr::FString(fstr) => { + // Evaluate f-string by concatenating all parts + let mut result = String::new(); + for part in &fstr.parts { + match part { + FStringPart::Text(text) => result.push_str(text), + FStringPart::Expr { expr, format } => { + let val = self.eval_expr(expr)?; + // TODO: Apply format specifier at comptime if needed + let _ = format; // Acknowledge format spec (unused at comptime for now) + result.push_str(&val.to_string()); + } + } + } + Ok(ComptimeValue::String(result)) + } + Expr::CharLit(lit) => Ok(ComptimeValue::Uint(lit.value as u64)), + Expr::Null(_) => Ok(ComptimeValue::Null), + Expr::Undefined(_) => Ok(ComptimeValue::Undefined), + Expr::Unit(_) => Ok(ComptimeValue::Unit), + + Expr::Ident(ident) => { + // First check local context + if let Some(val) = self.context.lookup(&ident.name) { + return Ok(val.clone()); + } + + // Check for built-in types (u8, u32, i64, bool, etc.) + if let Some(ty) = self.resolve_builtin_type(&ident.name) { + return Ok(ComptimeValue::Type(ty)); + } + + Err(ComptimeError { + message: format!("undefined variable '{}' at comptime", ident.name), + }) + } + + Expr::Binary(binary) => { + let left = self.eval_expr(&binary.left)?; + let right = self.eval_expr(&binary.right)?; + self.eval_binary_op(binary.op, &left, &right) + } + + Expr::Unary(unary) => { + let operand = self.eval_expr(&unary.operand)?; + self.eval_unary_op(unary.op, &operand) + } + + Expr::If(if_expr) => { + let cond = self.eval_expr(&if_expr.condition)?; + if cond.is_truthy() { + self.eval_expr(&if_expr.then_expr) + } else { + self.eval_expr(&if_expr.else_expr) + } + } + + Expr::Block(block) => { + self.context.push_scope(); + + for stmt in &block.statements { + self.eval_stmt(stmt)?; + } + + // Evaluate trailing expression if present (block's return value) + let result = if let Some(trailing) = &block.trailing_expr { + self.eval_expr(trailing)? + } else { + ComptimeValue::Unit + }; + + self.context.pop_scope(); + Ok(result) + } + + Expr::Comptime(comptime) => { + // Already in comptime context, just evaluate inner + self.eval_expr(&comptime.inner) + } + + Expr::Index(index) => { + let object = self.eval_expr(&index.object)?; + let idx = self.eval_expr(&index.index)?; + + match (&object, &idx) { + (ComptimeValue::Array(arr), ComptimeValue::Int(i)) => { + let i = *i as usize; + arr.get(i).cloned().ok_or_else(|| ComptimeError { + message: format!( + "index {} out of bounds for array of length {}", + i, + arr.len() + ), + }) + } + (ComptimeValue::Array(arr), ComptimeValue::Uint(i)) => { + let i = *i as usize; + arr.get(i).cloned().ok_or_else(|| ComptimeError { + message: format!( + "index {} out of bounds for array of length {}", + i, + arr.len() + ), + }) + } + _ => Err(ComptimeError { + message: format!("cannot index {} with {}", object, idx), + }), + } + } + + Expr::Field(field) => { + let object = self.eval_expr(&field.object)?; + + match object { + ComptimeValue::Struct { fields, .. } => fields + .get(&field.field) + .cloned() + .ok_or_else(|| ComptimeError { + message: format!("no field '{}' on struct", field.field), + }), + _ => Err(ComptimeError { + message: format!("cannot access field on {}", object), + }), + } + } + + Expr::Range(range) => { + let start = range + .start + .as_ref() + .map(|e| self.eval_expr(e)) + .transpose()? + .unwrap_or(ComptimeValue::Int(0)); + let end = range + .end + .as_ref() + .map(|e| self.eval_expr(e)) + .transpose()? + .ok_or_else(|| ComptimeError { + message: "range must have an end".to_string(), + })?; + + match (&start, &end) { + (ComptimeValue::Int(s), ComptimeValue::Int(e)) => { + let arr: Vec = (*s..*e).map(ComptimeValue::Int).collect(); + Ok(ComptimeValue::Array(arr)) + } + (ComptimeValue::Uint(s), ComptimeValue::Uint(e)) => { + let arr: Vec = (*s..*e).map(ComptimeValue::Uint).collect(); + Ok(ComptimeValue::Array(arr)) + } + _ => Err(ComptimeError { + message: "range bounds must be integers".to_string(), + }), + } + } + + Expr::ArrayInit(arr) => { + let values: ComptimeResult> = + arr.elements.iter().map(|e| self.eval_expr(e)).collect(); + Ok(ComptimeValue::Array(values?)) + } + + Expr::BracketArray(arr) => { + let values: ComptimeResult> = + arr.elements.iter().map(|e| self.eval_expr(e)).collect(); + Ok(ComptimeValue::Array(values?)) + } + + // Comptime function calls + Expr::Call(call) => { + // First, resolve the callee + let callee = self.eval_expr(&call.callee)?; + + match callee { + ComptimeValue::Function(func) => { + // Evaluate arguments + let mut arg_values = Vec::new(); + for arg in &call.args { + arg_values.push(self.eval_expr(arg)?); + } + + // Check argument count + if arg_values.len() != func.params.len() { + return Err(ComptimeError { + message: format!( + "function '{}' expects {} arguments, got {}", + func.name, + func.params.len(), + arg_values.len() + ), + }); + } + + // Check memoization cache + let cache_key = ( + func.name.clone(), + Self::serialize_args_for_cache(&arg_values), + ); + if let Some(cached_result) = self.memo_cache.get(&cache_key) { + return Ok(cached_result.clone()); + } + + // Create new scope for function execution + self.context.push_scope(); + + // Clone arg_values for binding (we need them for caching too) + // Bind parameters to argument values + for (param, value) in func.params.iter().zip(arg_values.clone()) { + self.context.define(¶m.name, value); + } + + // Execute function body + let mut result = ComptimeValue::Unit; + for stmt in &func.body.statements { + // Check for early return + if let Stmt::Return(ret) = stmt { + result = if let Some(value) = &ret.value { + self.eval_expr(value)? + } else { + ComptimeValue::Unit + }; + self.context.pop_scope(); + // Cache the result before returning + self.memo_cache.insert(cache_key, result.clone()); + return Ok(result); + } + self.eval_stmt(stmt)?; + } + + // Evaluate trailing expression if present + if let Some(trailing) = &func.body.trailing_expr { + result = self.eval_expr(trailing)?; + } + + self.context.pop_scope(); + + // Cache the result + self.memo_cache.insert(cache_key, result.clone()); + Ok(result) + } + _ => Err(ComptimeError { + message: format!("cannot call non-function value: {}", callee), + }), + } + } + + Expr::SlotRef(_) => Err(ComptimeError { + message: "qubit references not supported at comptime".to_string(), + }), + + Expr::BitRef(_) => Err(ComptimeError { + message: "bit references not supported at comptime".to_string(), + }), + + Expr::Builtin(builtin) => self.eval_builtin(builtin), + + Expr::AnonStruct(anon) => { + // Convert anonymous struct expression to a Type value + let mut fields = Vec::new(); + for field in &anon.fields { + let field_ty = self.resolve_type_expr(&field.ty)?; + fields.push((field.name.clone(), field_ty)); + } + Ok(ComptimeValue::Type(Type::Struct { + name: "".to_string(), + fields, + })) + } + + Expr::StructInit(_) => Err(ComptimeError { + message: "struct init not yet supported at comptime".to_string(), + }), + + Expr::Tuple(tuple) => { + let values: ComptimeResult> = + tuple.elements.iter().map(|e| self.eval_expr(e)).collect(); + Ok(ComptimeValue::Array(values?)) // Represent tuples as arrays for now + } + + Expr::Set(_) => Err(ComptimeError { + message: "set literals not supported at comptime".to_string(), + }), + + // Error/fault handling expressions + Expr::ErrorValue(_) => Err(ComptimeError { + message: "error values not supported at comptime".to_string(), + }), + + Expr::FaultValue(_) => Err(ComptimeError { + message: "fault values not supported at comptime".to_string(), + }), + + Expr::Catch(_) => Err(ComptimeError { + message: "catch expressions not supported at comptime".to_string(), + }), + + Expr::TryBlock(_) => Err(ComptimeError { + message: "try blocks not supported at comptime".to_string(), + }), + + // Batch apply is a runtime operation + Expr::BatchApply(_) => Err(ComptimeError { + message: "batch apply not supported at comptime".to_string(), + }), + + // Measurement is a runtime operation + Expr::Measure(_) => Err(ComptimeError { + message: "measurement not supported at comptime".to_string(), + }), + + // Gate operations are runtime-only + Expr::Gate(_) => Err(ComptimeError { + message: "gate operations not supported at comptime".to_string(), + }), + + // Function literal - creates a comptime function value + Expr::FnLit(func) => Ok(ComptimeValue::Function(func.clone())), + + // Channel expressions (@emit.log.*, @emit.sim.*, @emit.hw.*, custom channels) + // At comptime, these evaluate to unit (actual behavior happens at runtime) + Expr::Channel(channel) => { + // Evaluate all argument expressions at comptime + for arg in &channel.args { + self.eval_expr(arg.value())?; + } + Ok(ComptimeValue::Unit) + } + + // Result expressions - emit tagged values to caller + // At comptime, these evaluate to unit (emission happens at runtime) + Expr::Result(_result) => { + // result() always evaluates to unit + // The actual emission happens at runtime + Ok(ComptimeValue::Unit) + } + } + } + + /// Evaluate a for range to get iterable values. + fn eval_for_range(&mut self, range: &ForRange) -> ComptimeResult> { + match range { + ForRange::Range { start, end } => { + let start_val = self.eval_expr(start)?; + let end_val = self.eval_expr(end)?; + + match (&start_val, &end_val) { + (ComptimeValue::Int(s), ComptimeValue::Int(e)) => { + Ok((*s..*e).map(ComptimeValue::Int).collect()) + } + (ComptimeValue::Uint(s), ComptimeValue::Uint(e)) => { + Ok((*s..*e).map(ComptimeValue::Uint).collect()) + } + _ => Err(ComptimeError { + message: "range bounds must be integers".to_string(), + }), + } + } + ForRange::Collection(expr) => { + let val = self.eval_expr(expr)?; + match val { + ComptimeValue::Array(arr) => Ok(arr), + _ => Err(ComptimeError { + message: "cannot iterate over non-array at comptime".to_string(), + }), + } + } + } + } + + /// Evaluate a builtin function at compile time. + fn eval_builtin(&mut self, builtin: &crate::ast::BuiltinExpr) -> ComptimeResult { + match builtin.name.as_str() { + // Support both snake_case (preferred) and camelCase (legacy) for builtins + "size_of" | "sizeOf" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@size_of requires a type argument".to_string(), + }); + } + // Get the type name from the argument + let size = self.get_type_size(&builtin.args[0])?; + Ok(ComptimeValue::Uint(size)) + } + "type_name" | "typeName" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@type_name requires a type argument".to_string(), + }); + } + let name = self.get_type_name(&builtin.args[0])?; + Ok(ComptimeValue::String(name)) + } + "align_of" | "alignOf" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@align_of requires a type argument".to_string(), + }); + } + // For simplicity, alignment equals size for primitive types + let align = self.get_type_size(&builtin.args[0])?; + Ok(ComptimeValue::Uint(align)) + } + "type_info" | "typeInfo" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@type_info requires a type argument".to_string(), + }); + } + self.eval_type_info(&builtin.args[0]) + } + "field_names" | "fieldNames" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@field_names requires a type argument".to_string(), + }); + } + self.eval_field_names(&builtin.args[0]) + } + "enum_fields" | "enumFields" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@enum_fields requires a type argument".to_string(), + }); + } + self.eval_enum_fields(&builtin.args[0]) + } + "type_from_info" | "Type" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@type_from_info requires a TypeInfo argument".to_string(), + }); + } + self.eval_type_from_info(&builtin.args[0]) + } + _ => Err(ComptimeError { + message: format!("builtin @{} not supported at comptime", builtin.name), + }), + } + } + + /// Get the size of a type expression in bytes. + fn get_type_size(&self, expr: &Expr) -> ComptimeResult { + match expr { + Expr::Ident(ident) => { + match ident.name.as_str() { + "u8" | "i8" | "bool" => Ok(1), + "u16" | "i16" => Ok(2), + "u32" | "i32" | "f32" => Ok(4), + "u64" | "i64" | "f64" | "usize" | "isize" => Ok(8), + "u128" | "i128" => Ok(16), + "a64" => Ok(8), // Angle type + "unit" => Ok(0), + // Qubit and allocator are abstract, but we can assign sizes + "Qubit" | "qubit" => Ok(8), // Pointer-sized + _ => Err(ComptimeError { + message: format!("unknown type '{}' for @size_of", ident.name), + }), + } + } + // Pointer types + Expr::Unary(unary) if matches!(unary.op, UnaryOp::AddrOf) => { + Ok(8) // Pointers are 8 bytes on 64-bit + } + _ => Err(ComptimeError { + message: "cannot determine size of complex type expression".to_string(), + }), + } + } + + /// Get the name of a type expression. + fn get_type_name(&self, expr: &Expr) -> ComptimeResult { + match expr { + Expr::Ident(ident) => Ok(ident.name.clone()), + _ => Err(ComptimeError { + message: "cannot determine name of complex type expression".to_string(), + }), + } + } + + /// Get the TypeInfoKind for a Type. + fn get_type_info_kind(ty: &Type) -> TypeInfoKind { + match ty { + Type::Bool + | Type::UInt { .. } + | Type::IInt { .. } + | Type::Usize + | Type::Isize + | Type::F16 + | Type::F32 + | Type::F64 + | Type::F128 + | Type::A64 => TypeInfoKind::Primitive, + Type::Array { .. } => TypeInfoKind::Array, + Type::Slice { .. } => TypeInfoKind::Slice, + Type::Set { .. } => TypeInfoKind::Struct, // Set is like a collection + Type::Pointer { .. } => TypeInfoKind::Pointer, + Type::Optional { .. } => TypeInfoKind::Optional, + Type::ErrorUnion { .. } | Type::CollectedErrors { .. } => TypeInfoKind::ErrorUnion, + Type::Struct { .. } => TypeInfoKind::Struct, + Type::Enum { .. } => TypeInfoKind::Enum, + Type::Union { .. } => TypeInfoKind::Union, + Type::ErrorSet { .. } => TypeInfoKind::ErrorSet, + Type::FaultSet { .. } => TypeInfoKind::FaultSet, + Type::Function { .. } => TypeInfoKind::Function, + Type::Tuple { .. } => TypeInfoKind::Tuple, + Type::Type => TypeInfoKind::Type, + Type::Unit => TypeInfoKind::Unit, + Type::Never => TypeInfoKind::Never, + Type::Qubit | Type::Bit | Type::Allocator { .. } => TypeInfoKind::Quantum, + Type::Comptime(inner) => Self::get_type_info_kind(inner), + Type::Module { .. } => TypeInfoKind::Struct, // Module is like a namespace + Type::AnyError | Type::AnyFault | Type::Unknown => TypeInfoKind::Unknown, + } + } + + /// Resolve a type from an expression (for @type_info and related builtins). + fn resolve_type_from_expr(&self, expr: &Expr) -> ComptimeResult { + match expr { + Expr::Ident(ident) => { + // Check if it's a type in the context + if let Some(val) = self.context.lookup(&ident.name) + && let ComptimeValue::Type(ty) = val + { + return Ok(ty.clone()); + } + // Try to resolve primitive types + match ident.name.as_str() { + "bool" => Ok(Type::Bool), + "u8" => Ok(Type::UInt { + bits: BitWidth::must(8), + }), + "u16" => Ok(Type::UInt { + bits: BitWidth::must(16), + }), + "u32" => Ok(Type::UInt { + bits: BitWidth::must(32), + }), + "u64" => Ok(Type::UInt { + bits: BitWidth::must(64), + }), + "u128" => Ok(Type::UInt { + bits: BitWidth::must(128), + }), + "i8" => Ok(Type::IInt { + bits: BitWidth::must(8), + }), + "i16" => Ok(Type::IInt { + bits: BitWidth::must(16), + }), + "i32" => Ok(Type::IInt { + bits: BitWidth::must(32), + }), + "i64" => Ok(Type::IInt { + bits: BitWidth::must(64), + }), + "i128" => Ok(Type::IInt { + bits: BitWidth::must(128), + }), + "usize" => Ok(Type::Usize), + "isize" => Ok(Type::Isize), + "f16" => Ok(Type::F16), + "f32" => Ok(Type::F32), + "f64" => Ok(Type::F64), + "f128" => Ok(Type::F128), + "a64" => Ok(Type::A64), + "unit" => Ok(Type::Unit), + "type" => Ok(Type::Type), + "never" => Ok(Type::Never), + "Qubit" | "qubit" => Ok(Type::Qubit), + "Bit" | "bit" => Ok(Type::Bit), + _ => Err(ComptimeError { + message: format!("unknown type '{}'", ident.name), + }), + } + } + _ => Err(ComptimeError { + message: "complex type expressions not yet supported in @type_info".to_string(), + }), + } + } + + /// Evaluate @type_info(T) - returns a struct with type information. + fn eval_type_info(&self, expr: &Expr) -> ComptimeResult { + let ty = self.resolve_type_from_expr(expr)?; + let kind = Self::get_type_info_kind(&ty); + + let mut fields = BTreeMap::new(); + fields.insert( + "kind".to_string(), + ComptimeValue::String(kind.as_str().to_string()), + ); + fields.insert("name".to_string(), ComptimeValue::String(ty.display_name())); + + // Add type-specific information + match &ty { + Type::Struct { + name, + fields: struct_fields, + } => { + let field_names: Vec = struct_fields + .iter() + .map(|(n, _)| ComptimeValue::String(n.clone())) + .collect(); + fields.insert("fields".to_string(), ComptimeValue::Array(field_names)); + fields.insert( + "struct_name".to_string(), + ComptimeValue::String(name.clone()), + ); + } + Type::Enum { name, variants } => { + let variant_names: Vec = variants + .iter() + .map(|v| ComptimeValue::String(v.clone())) + .collect(); + fields.insert("variants".to_string(), ComptimeValue::Array(variant_names)); + fields.insert("enum_name".to_string(), ComptimeValue::String(name.clone())); + } + Type::Union { + name, + fields: union_fields, + is_tagged, + } => { + let field_names: Vec = union_fields + .iter() + .map(|(n, _)| ComptimeValue::String(n.clone())) + .collect(); + fields.insert("fields".to_string(), ComptimeValue::Array(field_names)); + fields.insert( + "union_name".to_string(), + ComptimeValue::String(name.clone()), + ); + fields.insert("is_tagged".to_string(), ComptimeValue::Bool(*is_tagged)); + } + Type::ErrorSet { name, errors } => { + let error_names: Vec = errors + .iter() + .map(|(n, _)| ComptimeValue::String(n.clone())) + .collect(); + fields.insert("errors".to_string(), ComptimeValue::Array(error_names)); + fields.insert( + "error_set_name".to_string(), + ComptimeValue::String(name.clone()), + ); + } + Type::FaultSet { name, faults } => { + let fault_names: Vec = faults + .iter() + .map(|(n, _)| ComptimeValue::String(n.clone())) + .collect(); + fields.insert("faults".to_string(), ComptimeValue::Array(fault_names)); + fields.insert( + "fault_set_name".to_string(), + ComptimeValue::String(name.clone()), + ); + } + Type::Array { element, size } => { + fields.insert("element".to_string(), ComptimeValue::Type(*element.clone())); + if let Some(sz) = size { + fields.insert("size".to_string(), ComptimeValue::Uint(*sz)); + } + } + Type::Slice { element } => { + fields.insert("element".to_string(), ComptimeValue::Type(*element.clone())); + } + Type::Pointer { + pointee, + is_const, + is_many, + } => { + fields.insert("pointee".to_string(), ComptimeValue::Type(*pointee.clone())); + fields.insert("is_const".to_string(), ComptimeValue::Bool(*is_const)); + fields.insert("is_many".to_string(), ComptimeValue::Bool(*is_many)); + } + Type::Optional { inner } => { + fields.insert("child".to_string(), ComptimeValue::Type(*inner.clone())); + } + Type::ErrorUnion { error, payload } => { + fields.insert("error".to_string(), ComptimeValue::Type(*error.clone())); + fields.insert("payload".to_string(), ComptimeValue::Type(*payload.clone())); + } + Type::Function { + params, + return_type, + } => { + let param_types: Vec = params + .iter() + .map(|p| ComptimeValue::Type(p.clone())) + .collect(); + fields.insert("params".to_string(), ComptimeValue::Array(param_types)); + fields.insert( + "return_type".to_string(), + ComptimeValue::Type(*return_type.clone()), + ); + } + Type::Tuple { elements } => { + let element_types: Vec = elements + .iter() + .map(|e| ComptimeValue::Type(e.clone())) + .collect(); + fields.insert("elements".to_string(), ComptimeValue::Array(element_types)); + } + _ => { + // Primitive types don't have additional info + } + } + + Ok(ComptimeValue::Struct { + name: "TypeInfo".to_string(), + fields, + }) + } + + /// Evaluate @field_names(T) - returns an array of field name strings for structs. + fn eval_field_names(&self, expr: &Expr) -> ComptimeResult { + let ty = self.resolve_type_from_expr(expr)?; + + match &ty { + Type::Struct { fields, .. } => { + let names: Vec = fields + .iter() + .map(|(name, _)| ComptimeValue::String(name.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + Type::Union { fields, .. } => { + let names: Vec = fields + .iter() + .map(|(name, _)| ComptimeValue::String(name.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + _ => Err(ComptimeError { + message: format!( + "@field_names requires a struct or union type, got {}", + ty.display_name() + ), + }), + } + } + + /// Evaluate @enum_fields(T) - returns an array of enum variant names. + fn eval_enum_fields(&self, expr: &Expr) -> ComptimeResult { + let ty = self.resolve_type_from_expr(expr)?; + + match &ty { + Type::Enum { variants, .. } => { + let names: Vec = variants + .iter() + .map(|v| ComptimeValue::String(v.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + Type::ErrorSet { errors, .. } => { + let names: Vec = errors + .iter() + .map(|(name, _)| ComptimeValue::String(name.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + Type::FaultSet { faults, .. } => { + let names: Vec = faults + .iter() + .map(|(name, _)| ComptimeValue::String(name.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + _ => Err(ComptimeError { + message: format!( + "@enum_fields requires an enum, error set, or fault set type, got {}", + ty.display_name() + ), + }), + } + } + + /// Evaluate @Type(info) - construct a type from a TypeInfo struct. + /// This is the reverse of @type_info. + fn eval_type_from_info(&mut self, expr: &Expr) -> ComptimeResult { + let info = self.eval_expr(expr)?; + + match info { + ComptimeValue::Struct { name, fields } if name == "TypeInfo" => { + // Get the kind field + let kind = fields.get("kind").ok_or_else(|| ComptimeError { + message: "@Type requires TypeInfo with 'kind' field".to_string(), + })?; + + let kind_str = match kind { + ComptimeValue::String(s) => s.as_str(), + _ => { + return Err(ComptimeError { + message: "TypeInfo.kind must be a string".to_string(), + }); + } + }; + + // Construct the type based on kind + let ty = match kind_str { + "primitive" => { + // Get the name to determine which primitive + let name = fields + .get("name") + .and_then(|v| { + if let ComptimeValue::String(s) = v { + Some(s.as_str()) + } else { + None + } + }) + .ok_or_else(|| ComptimeError { + message: "primitive TypeInfo requires 'name' field".to_string(), + })?; + + match name { + "bool" => Type::Bool, + "u8" => Type::UInt { + bits: BitWidth::must(8), + }, + "u16" => Type::UInt { + bits: BitWidth::must(16), + }, + "u32" => Type::UInt { + bits: BitWidth::must(32), + }, + "u64" => Type::UInt { + bits: BitWidth::must(64), + }, + "i8" => Type::IInt { + bits: BitWidth::must(8), + }, + "i16" => Type::IInt { + bits: BitWidth::must(16), + }, + "i32" => Type::IInt { + bits: BitWidth::must(32), + }, + "i64" => Type::IInt { + bits: BitWidth::must(64), + }, + "f32" => Type::F32, + "f64" => Type::F64, + _ => { + return Err(ComptimeError { + message: format!("unknown primitive type '{}'", name), + }); + } + } + } + "array" => { + let element = fields.get("element").ok_or_else(|| ComptimeError { + message: "array TypeInfo requires 'element' field".to_string(), + })?; + let element_ty = match element { + ComptimeValue::Type(t) => t.clone(), + _ => { + return Err(ComptimeError { + message: "TypeInfo.element must be a type".to_string(), + }); + } + }; + let size = fields.get("size").and_then(|v| match v { + ComptimeValue::Uint(n) => Some(*n), + ComptimeValue::Int(n) if *n >= 0 => Some(*n as u64), + _ => None, + }); + Type::Array { + element: Box::new(element_ty), + size, + } + } + "slice" => { + let element = fields.get("element").ok_or_else(|| ComptimeError { + message: "slice TypeInfo requires 'element' field".to_string(), + })?; + let element_ty = match element { + ComptimeValue::Type(t) => t.clone(), + _ => { + return Err(ComptimeError { + message: "TypeInfo.element must be a type".to_string(), + }); + } + }; + Type::Slice { + element: Box::new(element_ty), + } + } + "optional" => { + let child = fields.get("child").ok_or_else(|| ComptimeError { + message: "optional TypeInfo requires 'child' field".to_string(), + })?; + let child_ty = match child { + ComptimeValue::Type(t) => t.clone(), + _ => { + return Err(ComptimeError { + message: "TypeInfo.child must be a type".to_string(), + }); + } + }; + Type::Optional { + inner: Box::new(child_ty), + } + } + "unit" => Type::Unit, + "never" => Type::Never, + "type" => Type::Type, + _ => { + return Err(ComptimeError { + message: format!("cannot construct type from kind '{}'", kind_str), + }); + } + }; + + Ok(ComptimeValue::Type(ty)) + } + _ => Err(ComptimeError { + message: "@Type requires a TypeInfo struct argument".to_string(), + }), + } + } + + /// Evaluate a statement at compile time. + pub fn eval_stmt(&mut self, stmt: &Stmt) -> ComptimeResult { + match stmt { + Stmt::Binding(binding) => { + let value = if let Some(init) = &binding.value { + self.eval_expr(init)? + } else { + ComptimeValue::Undefined + }; + self.context.define(&binding.name, value); + Ok(ComptimeValue::Undefined) + } + + Stmt::Alias(alias) => { + // Aliases are evaluated as their source expression at comptime + let value = self.eval_expr(&alias.source)?; + self.context.define(&alias.name, value); + Ok(ComptimeValue::Undefined) + } + + Stmt::Assign(assign) => { + let value = self.eval_expr(&assign.value)?; + + // Handle simple identifier assignment + if let Expr::Ident(ident) = &assign.target { + if !self.context.update(&ident.name, value) { + return Err(ComptimeError { + message: format!("undefined variable '{}'", ident.name), + }); + } + } else { + return Err(ComptimeError { + message: "complex assignment targets not supported at comptime".to_string(), + }); + } + Ok(ComptimeValue::Undefined) + } + + Stmt::Expr(expr_stmt) => { + self.eval_expr(&expr_stmt.expr)?; + Ok(ComptimeValue::Undefined) + } + + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + self.eval_expr(value) + } else { + Ok(ComptimeValue::Undefined) + } + } + + Stmt::If(if_stmt) => { + let cond = self.eval_expr(&if_stmt.condition)?; + + if cond.is_truthy() { + self.context.push_scope(); + for stmt in &if_stmt.then_body.statements { + self.eval_stmt(stmt)?; + } + self.context.pop_scope(); + } else if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + crate::ast::ElseBranch::Else(block) => { + self.context.push_scope(); + for stmt in &block.statements { + self.eval_stmt(stmt)?; + } + self.context.pop_scope(); + } + crate::ast::ElseBranch::ElseIf(nested_if) => { + self.eval_stmt(&Stmt::If(*nested_if.clone()))?; + } + } + } + Ok(ComptimeValue::Undefined) + } + + Stmt::For(for_stmt) => { + let values = self.eval_for_range(&for_stmt.range)?; + + self.context.push_scope(); + + // Get binding name from captures + let binding = for_stmt.captures.first(); + + for value in values { + if let Some(name) = binding { + self.context.define(name, value); + } + for stmt in &for_stmt.body.statements { + self.eval_stmt(stmt)?; + } + } + + self.context.pop_scope(); + Ok(ComptimeValue::Undefined) + } + + Stmt::Block(block) => { + self.context.push_scope(); + for stmt in &block.statements { + self.eval_stmt(stmt)?; + } + // Evaluate trailing expression if present + let result = if let Some(trailing) = &block.trailing_expr { + self.eval_expr(trailing)? + } else { + ComptimeValue::Unit + }; + self.context.pop_scope(); + Ok(result) + } + + Stmt::Defer(_) => Err(ComptimeError { + message: "defer not supported at comptime".to_string(), + }), + + Stmt::Errdefer(_) => Err(ComptimeError { + message: "errdefer not supported at comptime".to_string(), + }), + + Stmt::Break(_) => Err(ComptimeError { + message: "break not yet supported at comptime".to_string(), + }), + + Stmt::Continue(_) => Err(ComptimeError { + message: "continue not yet supported at comptime".to_string(), + }), + + Stmt::Switch(_) => Err(ComptimeError { + message: "switch not yet supported at comptime".to_string(), + }), + + Stmt::Tick(_) => Err(ComptimeError { + message: "tick blocks not supported at comptime".to_string(), + }), + + Stmt::TryBlock(_) => Err(ComptimeError { + message: "try blocks not supported at comptime".to_string(), + }), + + Stmt::Gate(_) => Err(ComptimeError { + message: "quantum operations not supported at comptime".to_string(), + }), + + Stmt::Prepare(_) => Err(ComptimeError { + message: "prepare operations not supported at comptime".to_string(), + }), + + Stmt::Measure(_) => Err(ComptimeError { + message: "measurement operations not supported at comptime".to_string(), + }), + + Stmt::Barrier(_) => Err(ComptimeError { + message: "barrier operations not supported at comptime".to_string(), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_comptime_value_display() { + assert_eq!(ComptimeValue::Int(42).to_string(), "42"); + assert_eq!(ComptimeValue::Bool(true).to_string(), "true"); + assert_eq!(ComptimeValue::Null.to_string(), "null"); + } + + #[test] + fn test_comptime_arithmetic() { + let eval = ComptimeEvaluator::new(); + + let a = ComptimeValue::Int(10); + let b = ComptimeValue::Int(3); + + assert_eq!( + eval.eval_binary_op(BinaryOp::Add, &a, &b).unwrap(), + ComptimeValue::Int(13) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Sub, &a, &b).unwrap(), + ComptimeValue::Int(7) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Mul, &a, &b).unwrap(), + ComptimeValue::Int(30) + ); + // 10/3 is not exact, so returns Rational (prevents subtle bugs like 1/4 turns = 0) + assert_eq!( + eval.eval_binary_op(BinaryOp::Div, &a, &b).unwrap(), + ComptimeValue::Rational(Rational::new(10, 3)) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Mod, &a, &b).unwrap(), + ComptimeValue::Int(1) + ); + } + + #[test] + fn test_comptime_comparison() { + let eval = ComptimeEvaluator::new(); + + let a = ComptimeValue::Int(10); + let b = ComptimeValue::Int(3); + + assert_eq!( + eval.eval_binary_op(BinaryOp::Lt, &a, &b).unwrap(), + ComptimeValue::Bool(false) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Gt, &a, &b).unwrap(), + ComptimeValue::Bool(true) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Eq, &a, &b).unwrap(), + ComptimeValue::Bool(false) + ); + } + + #[test] + fn test_comptime_context() { + let mut ctx = ComptimeContext::new(); + + ctx.define("x", ComptimeValue::Int(42)); + assert_eq!(ctx.lookup("x"), Some(&ComptimeValue::Int(42))); + + ctx.push_scope(); + ctx.define("y", ComptimeValue::Int(10)); + assert_eq!(ctx.lookup("x"), Some(&ComptimeValue::Int(42))); + assert_eq!(ctx.lookup("y"), Some(&ComptimeValue::Int(10))); + + ctx.pop_scope(); + assert_eq!(ctx.lookup("x"), Some(&ComptimeValue::Int(42))); + assert_eq!(ctx.lookup("y"), None); + } + + #[test] + fn test_comptime_unary() { + let eval = ComptimeEvaluator::new(); + + let a = ComptimeValue::Int(42); + assert_eq!( + eval.eval_unary_op(UnaryOp::Neg, &a).unwrap(), + ComptimeValue::Int(-42) + ); + + let b = ComptimeValue::Bool(true); + assert_eq!( + eval.eval_unary_op(UnaryOp::Not, &b).unwrap(), + ComptimeValue::Bool(false) + ); + } + + #[test] + fn test_comptime_division_by_zero() { + let eval = ComptimeEvaluator::new(); + + let a = ComptimeValue::Int(10); + let b = ComptimeValue::Int(0); + + let result = eval.eval_binary_op(BinaryOp::Div, &a, &b); + assert!(result.is_err()); + } + + #[test] + fn test_comptime_orelse() { + let eval = ComptimeEvaluator::new(); + + let null = ComptimeValue::Null; + let fallback = ComptimeValue::Int(42); + + assert_eq!( + eval.eval_binary_op(BinaryOp::Orelse, &null, &fallback) + .unwrap(), + ComptimeValue::Int(42) + ); + + let some = ComptimeValue::Int(10); + assert_eq!( + eval.eval_binary_op(BinaryOp::Orelse, &some, &fallback) + .unwrap(), + ComptimeValue::Int(10) + ); + } + + #[test] + fn test_comptime_sizeof() { + use crate::ast::{BuiltinExpr, Ident}; + + let mut eval = ComptimeEvaluator::new(); + + // Test @sizeOf(u8) + let builtin = BuiltinExpr { + name: "sizeOf".to_string(), + args: vec![Expr::Ident(Ident { + name: "u8".to_string(), + location: None, + })], + location: None, + }; + let result = eval.eval_builtin(&builtin).unwrap(); + assert_eq!(result, ComptimeValue::Uint(1)); + + // Test @sizeOf(u32) + let builtin = BuiltinExpr { + name: "sizeOf".to_string(), + args: vec![Expr::Ident(Ident { + name: "u32".to_string(), + location: None, + })], + location: None, + }; + let result = eval.eval_builtin(&builtin).unwrap(); + assert_eq!(result, ComptimeValue::Uint(4)); + + // Test @sizeOf(u64) + let builtin = BuiltinExpr { + name: "sizeOf".to_string(), + args: vec![Expr::Ident(Ident { + name: "u64".to_string(), + location: None, + })], + location: None, + }; + let result = eval.eval_builtin(&builtin).unwrap(); + assert_eq!(result, ComptimeValue::Uint(8)); + } + + #[test] + fn test_comptime_typename() { + use crate::ast::{BuiltinExpr, Ident}; + + let mut eval = ComptimeEvaluator::new(); + + let builtin = BuiltinExpr { + name: "typeName".to_string(), + args: vec![Expr::Ident(Ident { + name: "i32".to_string(), + location: None, + })], + location: None, + }; + let result = eval.eval_builtin(&builtin).unwrap(); + assert_eq!(result, ComptimeValue::String("i32".to_string())); + } + + #[test] + fn test_comptime_to_usize() { + assert_eq!(ComptimeValue::Int(42).to_usize(), Some(42)); + assert_eq!(ComptimeValue::Uint(100).to_usize(), Some(100)); + assert_eq!(ComptimeValue::Bool(true).to_usize(), None); + assert_eq!(ComptimeValue::Null.to_usize(), None); + } + + #[test] + fn test_comptime_type_values() { + let eval = ComptimeEvaluator::new(); + + // Test that built-in type names resolve to Type values + assert!(eval.resolve_builtin_type("u8").is_some()); + assert!(eval.resolve_builtin_type("u32").is_some()); + assert!(eval.resolve_builtin_type("bool").is_some()); + assert!(eval.resolve_builtin_type("unknown_type").is_none()); + } + + #[test] + fn test_comptime_function_value() { + use crate::ast::{Block, Param, TypeExpr}; + + // Create a simple comptime function that returns a type + let func = FnDecl { + name: "makeArray".to_string(), + params: vec![Param { + name: "T".to_string(), + ty: TypeExpr::Type, + is_comptime: true, + location: None, + }], + return_type: Some(TypeExpr::Type), + body: Block { + label: None, + attrs: vec![], + statements: vec![], + trailing_expr: Some(Box::new(Expr::Ident(crate::ast::Ident { + name: "T".to_string(), + location: None, + }))), + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + let func_value = ComptimeValue::Function(Box::new(func)); + assert!(matches!(func_value.get_type(), Type::Type)); + } + + #[test] + fn test_comptime_anon_struct() { + use crate::ast::{AnonStructExpr, PrimitiveType, StructField, TypeExpr}; + + let mut eval = ComptimeEvaluator::new(); + + // Create anonymous struct expression: struct { x: u32, y: u32 } + let anon = AnonStructExpr { + fields: vec![ + StructField { + name: "x".to_string(), + ty: TypeExpr::Primitive(PrimitiveType::UInt { bits: 32 }), + default: None, + doc_comment: None, + location: None, + }, + StructField { + name: "y".to_string(), + ty: TypeExpr::Primitive(PrimitiveType::UInt { bits: 32 }), + default: None, + doc_comment: None, + location: None, + }, + ], + is_packed: false, + location: None, + }; + + let result = eval.eval_expr(&Expr::AnonStruct(Box::new(anon))).unwrap(); + + // Verify it's a Type value with a Struct type + if let ComptimeValue::Type(ty) = result { + if let Type::Struct { fields, .. } = ty { + assert_eq!(fields.len(), 2); + assert_eq!(fields[0].0, "x"); + assert_eq!(fields[1].0, "y"); + } else { + panic!("Expected Struct type"); + } + } else { + panic!("Expected Type value"); + } + } + + #[test] + fn test_comptime_function_call() { + use crate::ast::{Block, CallExpr, Ident, Param, TypeExpr}; + + let mut eval = ComptimeEvaluator::new(); + + // Create a function: fn(comptime T: type) -> type { T } + // This is an identity function for types + let func = FnDecl { + name: "identity".to_string(), + params: vec![Param { + name: "T".to_string(), + ty: TypeExpr::Type, + is_comptime: true, + location: None, + }], + return_type: Some(TypeExpr::Type), + body: Block { + label: None, + attrs: vec![], + statements: vec![], + trailing_expr: Some(Box::new(Expr::Ident(Ident { + name: "T".to_string(), + location: None, + }))), + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + // Store function in context + eval.context + .define("identity", ComptimeValue::Function(Box::new(func))); + + // Call identity(u32) + let call = CallExpr { + callee: Expr::Ident(Ident { + name: "identity".to_string(), + location: None, + }), + args: vec![Expr::Ident(Ident { + name: "u32".to_string(), + location: None, + })], + location: None, + }; + + let result = eval.eval_expr(&Expr::Call(Box::new(call))).unwrap(); + + // The result should be the u32 type + if let ComptimeValue::Type(ty) = result { + assert!(matches!(ty, Type::UInt { bits } if bits == BitWidth::BITS_32)); + } else { + panic!("Expected Type value, got {:?}", result); + } + } + + /// Test the full pattern: inline for + comptime function calls for type construction. + /// This is the key pattern that replaces recursion for building nested types. + /// + /// Simulates: + /// ```zlup + /// WrapArray := fn(comptime Inner: type) -> type { + /// struct { data: [7]Inner } + /// }; + /// + /// Code := comptime { + /// mut T := u8; + /// inline for _ in 0..3 { + /// T = WrapArray(T); + /// } + /// T + /// }; + /// ``` + #[test] + fn test_inline_for_with_comptime_function_nested_types() { + use crate::ast::{ + AnonStructExpr, ArrayType, Block, CallExpr, Ident, IntLit, Param, StructField, TypeExpr, + }; + + let mut eval = ComptimeEvaluator::new(); + + // Create WrapArray function: fn(comptime Inner: type) -> type { struct { data: [7]Inner } } + // The function body returns an anonymous struct with a field `data: [7]Inner` + let wrap_array_func = FnDecl { + name: "WrapArray".to_string(), + params: vec![Param { + name: "Inner".to_string(), + ty: TypeExpr::Type, + is_comptime: true, + location: None, + }], + return_type: Some(TypeExpr::Type), + body: Block { + label: None, + attrs: vec![], + statements: vec![], + // Return: struct { data: [7]Inner } + trailing_expr: Some(Box::new(Expr::AnonStruct(Box::new(AnonStructExpr { + fields: vec![StructField { + name: "data".to_string(), + ty: TypeExpr::Array(Box::new(ArrayType { + element: TypeExpr::Named(crate::ast::TypePath { + segments: vec!["Inner".to_string()], + location: None, + }), + size: Some(Expr::IntLit(IntLit { + value: 7, + suffix: None, + location: None, + })), + sentinel: None, + })), + default: None, + doc_comment: None, + location: None, + }], + is_packed: false, + location: None, + })))), + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + // Store function in context + eval.context.define( + "WrapArray", + ComptimeValue::Function(Box::new(wrap_array_func)), + ); + + // Now simulate the comptime block: + // comptime { + // mut T := u8; + // inline for _ in 0..3 { T = WrapArray(T); } + // T + // } + + // Step 1: mut T := u8; + eval.context.define( + "T", + ComptimeValue::Type(Type::UInt { + bits: BitWidth::BITS_8, + }), + ); + + // Step 2: Simulate inline for with 3 iterations + for _ in 0..3 { + // T = WrapArray(T) + let call = CallExpr { + callee: Expr::Ident(Ident { + name: "WrapArray".to_string(), + location: None, + }), + args: vec![Expr::Ident(Ident { + name: "T".to_string(), + location: None, + })], + location: None, + }; + + let new_type = eval.eval_expr(&Expr::Call(Box::new(call))).unwrap(); + eval.context.update("T", new_type); + } + + // Step 3: Get final T + let result = eval.context.lookup("T").unwrap().clone(); + + // Verify the structure: struct { data: [7]struct { data: [7]struct { data: [7]u8 } } } + if let ComptimeValue::Type(ty) = result { + // Level 1: struct { data: [7]... } + if let Type::Struct { fields, .. } = &ty { + assert_eq!(fields.len(), 1, "Expected 1 field at level 1"); + assert_eq!(fields[0].0, "data", "Field name should be 'data'"); + + // Level 1 field type: [7]struct { ... } + if let Type::Array { + element: level2, + size, + } = &fields[0].1 + { + assert_eq!(*size, Some(7), "Array size should be 7 at level 1"); + + // Level 2: struct { data: [7]... } + if let Type::Struct { + fields: fields2, .. + } = level2.as_ref() + { + assert_eq!(fields2.len(), 1, "Expected 1 field at level 2"); + + // Level 2 field type: [7]struct { ... } + if let Type::Array { + element: level3, + size: size2, + } = &fields2[0].1 + { + assert_eq!(*size2, Some(7), "Array size should be 7 at level 2"); + + // Level 3: struct { data: [7]u8 } + if let Type::Struct { + fields: fields3, .. + } = level3.as_ref() + { + assert_eq!(fields3.len(), 1, "Expected 1 field at level 3"); + + // Level 3 field type: [7]u8 + if let Type::Array { + element: inner, + size: size3, + } = &fields3[0].1 + { + assert_eq!( + *size3, + Some(7), + "Array size should be 7 at level 3" + ); + assert!( + matches!(inner.as_ref(), Type::UInt { bits } if *bits == BitWidth::BITS_8), + "Innermost type should be u8, got {:?}", + inner + ); + } else { + panic!("Level 3 field should be array, got {:?}", fields3[0].1); + } + } else { + panic!("Level 3 should be struct, got {:?}", level3); + } + } else { + panic!("Level 2 field should be array, got {:?}", fields2[0].1); + } + } else { + panic!("Level 2 should be struct, got {:?}", level2); + } + } else { + panic!("Level 1 field should be array, got {:?}", fields[0].1); + } + } else { + panic!("Result should be struct, got {:?}", ty); + } + } else { + panic!("Expected Type value, got {:?}", result); + } + } + + /// Test that inline for loop in comptime block works end-to-end. + /// Uses the actual for loop evaluation, not manual simulation. + #[test] + fn test_comptime_block_with_inline_for() { + use crate::ast::{AssignStmt, Block, ForRange, ForStmt, Ident, IntLit, Stmt}; + + let mut eval = ComptimeEvaluator::new(); + + // Simulate: + // comptime { + // mut sum := 0; + // for i in 0..5 { sum = sum + i; } + // sum + // } + + // Create the for loop + let for_stmt = ForStmt { + captures: vec!["i".to_string()], + range: ForRange::Range { + start: Expr::IntLit(IntLit { + value: 0, + suffix: None, + location: None, + }), + end: Expr::IntLit(IntLit { + value: 5, + suffix: None, + location: None, + }), + }, + body: Block { + label: None, + attrs: vec![], + statements: vec![ + // sum = sum + i + Stmt::Assign(AssignStmt { + target: Expr::Ident(Ident { + name: "sum".to_string(), + location: None, + }), + op: crate::ast::AssignOp::Assign, + value: Expr::Binary(Box::new(crate::ast::BinaryExpr { + left: Expr::Ident(Ident { + name: "sum".to_string(), + location: None, + }), + op: BinaryOp::Add, + right: Expr::Ident(Ident { + name: "i".to_string(), + location: None, + }), + location: None, + })), + location: None, + }), + ], + trailing_expr: None, + location: None, + }, + is_inline: true, + label: None, + location: None, + }; + + // Initialize sum + eval.context.define("sum", ComptimeValue::Int(0)); + + // Execute the for loop + eval.eval_stmt(&Stmt::For(for_stmt)).unwrap(); + + // Check result: 0 + 1 + 2 + 3 + 4 = 10 + let result = eval.context.lookup("sum").unwrap(); + assert_eq!(*result, ComptimeValue::Int(10), "Sum should be 10"); + } + + #[test] + fn test_fraction_division_returns_rational() { + // Integer division that's not exact should return Rational + // This is critical for angle expressions like `1/4 turns` + let eval = ComptimeEvaluator::new(); + + // 1/4 should be Rational(1/4), not 0 or Float + let one = ComptimeValue::Int(1); + let four = ComptimeValue::Int(4); + let result = eval.eval_div(&one, &four).unwrap(); + assert_eq!( + result, + ComptimeValue::Rational(Rational::new(1, 4)), + "1/4 should be Rational(1/4)" + ); + // Verify it converts to correct float + assert_eq!(result.as_float(), Some(0.25), "1/4 as float should be 0.25"); + + // 1/8 should be Rational(1/8) + let eight = ComptimeValue::Int(8); + let result = eval.eval_div(&one, &eight).unwrap(); + assert_eq!( + result, + ComptimeValue::Rational(Rational::new(1, 8)), + "1/8 should be Rational(1/8)" + ); + assert_eq!( + result.as_float(), + Some(0.125), + "1/8 as float should be 0.125" + ); + + // 4/2 is exact, should return Int + let two = ComptimeValue::Int(2); + let result = eval.eval_div(&four, &two).unwrap(); + assert_eq!(result, ComptimeValue::Int(2), "4/2 should be Int(2)"); + + // 10/5 is exact, should return Int + let ten = ComptimeValue::Int(10); + let five = ComptimeValue::Int(5); + let result = eval.eval_div(&ten, &five).unwrap(); + assert_eq!(result, ComptimeValue::Int(2), "10/5 should be Int(2)"); + } + + #[test] + fn test_rational_arithmetic() { + let eval = ComptimeEvaluator::new(); + + // 1/4 + 1/4 = 1/2 + let quarter = ComptimeValue::Rational(Rational::new(1, 4)); + let result = eval.eval_add(&quarter, &quarter).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 2))); + + // 1/2 - 1/4 = 1/4 + let half = ComptimeValue::Rational(Rational::new(1, 2)); + let result = eval.eval_sub(&half, &quarter).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 4))); + + // 1/4 * 2 = 1/2 + let two = ComptimeValue::Int(2); + let result = eval.eval_mul(&quarter, &two).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 2))); + + // 1/2 / 2 = 1/4 + let result = eval.eval_div(&half, &two).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 4))); + + // 1/3 + 1/3 + 1/3 = 1 + let third = ComptimeValue::Rational(Rational::new(1, 3)); + let two_thirds = eval.eval_add(&third, &third).unwrap(); + let result = eval.eval_add(&two_thirds, &third).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 1))); + } + + #[test] + fn test_rational_comparison() { + let eval = ComptimeEvaluator::new(); + + let quarter = ComptimeValue::Rational(Rational::new(1, 4)); + let half = ComptimeValue::Rational(Rational::new(1, 2)); + let one = ComptimeValue::Int(1); + + // 1/4 < 1/2 + assert_eq!( + eval.eval_lt(&quarter, &half).unwrap(), + ComptimeValue::Bool(true) + ); + + // 1/4 < 1 + assert_eq!( + eval.eval_lt(&quarter, &one).unwrap(), + ComptimeValue::Bool(true) + ); + + // 1/2 > 1/4 + assert_eq!( + eval.eval_gt(&half, &quarter).unwrap(), + ComptimeValue::Bool(true) + ); + } + + #[test] + fn test_rational_negation() { + let eval = ComptimeEvaluator::new(); + + let quarter = ComptimeValue::Rational(Rational::new(1, 4)); + let result = eval.eval_unary_op(UnaryOp::Neg, &quarter).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(-1, 4))); + } + + // ========================================================================= + // Advanced Builtin Tests (@type_info, @field_names, @enum_fields) + // ========================================================================= + + #[test] + fn test_type_info_kind() { + use crate::semantic::Type; + + // Test primitive types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Bool), + TypeInfoKind::Primitive + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::UInt { + bits: BitWidth::must(32) + }), + TypeInfoKind::Primitive + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::F64), + TypeInfoKind::Primitive + ); + + // Test compound types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Array { + element: Box::new(Type::Bool), + size: Some(4) + }), + TypeInfoKind::Array + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Slice { + element: Box::new(Type::Bool) + }), + TypeInfoKind::Slice + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Optional { + inner: Box::new(Type::Bool) + }), + TypeInfoKind::Optional + ); + + // Test user-defined types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Struct { + name: "Point".to_string(), + fields: vec![("x".to_string(), Type::F64), ("y".to_string(), Type::F64),], + }), + TypeInfoKind::Struct + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Enum { + name: "Color".to_string(), + variants: vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()], + }), + TypeInfoKind::Enum + ); + + // Test special types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Unit), + TypeInfoKind::Unit + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Never), + TypeInfoKind::Never + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Type), + TypeInfoKind::Type + ); + + // Test quantum types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Qubit), + TypeInfoKind::Quantum + ); + } + + #[test] + fn test_type_info_kind_as_str() { + assert_eq!(TypeInfoKind::Primitive.as_str(), "primitive"); + assert_eq!(TypeInfoKind::Array.as_str(), "array"); + assert_eq!(TypeInfoKind::Struct.as_str(), "struct"); + assert_eq!(TypeInfoKind::Enum.as_str(), "enum"); + assert_eq!(TypeInfoKind::Optional.as_str(), "optional"); + assert_eq!(TypeInfoKind::Quantum.as_str(), "quantum"); + } + + #[test] + fn test_resolve_type_from_expr_primitives() { + use crate::ast::{Ident, SourceLocation}; + + let eval = ComptimeEvaluator::new(); + + // Test primitive type resolution + let bool_expr = Expr::Ident(Ident { + name: "bool".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.resolve_type_from_expr(&bool_expr).unwrap(); + assert_eq!(result, Type::Bool); + + let u32_expr = Expr::Ident(Ident { + name: "u32".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.resolve_type_from_expr(&u32_expr).unwrap(); + assert_eq!( + result, + Type::UInt { + bits: BitWidth::must(32) + } + ); + + let f64_expr = Expr::Ident(Ident { + name: "f64".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.resolve_type_from_expr(&f64_expr).unwrap(); + assert_eq!(result, Type::F64); + } + + #[test] + fn test_eval_type_info_primitive() { + use crate::ast::{Ident, SourceLocation}; + + let eval = ComptimeEvaluator::new(); + + let u32_expr = Expr::Ident(Ident { + name: "u32".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.eval_type_info(&u32_expr).unwrap(); + + // Should be a struct with kind and name + if let ComptimeValue::Struct { name, fields } = result { + assert_eq!(name, "TypeInfo"); + assert_eq!( + fields.get("kind"), + Some(&ComptimeValue::String("primitive".to_string())) + ); + // Name should be "u32" + if let Some(ComptimeValue::String(type_name)) = fields.get("name") { + assert!(type_name.contains("u32") || type_name.contains("UInt")); + } + } else { + panic!("Expected TypeInfo struct"); + } + } + + #[test] + fn test_eval_field_names_struct() { + use crate::ast::{Ident, SourceLocation}; + + let mut eval = ComptimeEvaluator::new(); + + // Register a struct type in context + eval.context.define( + "Point", + ComptimeValue::Type(Type::Struct { + name: "Point".to_string(), + fields: vec![ + ("x".to_string(), Type::F64), + ("y".to_string(), Type::F64), + ("z".to_string(), Type::F64), + ], + }), + ); + + let point_expr = Expr::Ident(Ident { + name: "Point".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.eval_field_names(&point_expr).unwrap(); + + // Should be an array of strings + if let ComptimeValue::Array(names) = result { + assert_eq!(names.len(), 3); + assert_eq!(names[0], ComptimeValue::String("x".to_string())); + assert_eq!(names[1], ComptimeValue::String("y".to_string())); + assert_eq!(names[2], ComptimeValue::String("z".to_string())); + } else { + panic!("Expected array of field names"); + } + } + + #[test] + fn test_eval_enum_fields() { + use crate::ast::{Ident, SourceLocation}; + + let mut eval = ComptimeEvaluator::new(); + + // Register an enum type in context + eval.context.define( + "Color", + ComptimeValue::Type(Type::Enum { + name: "Color".to_string(), + variants: vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()], + }), + ); + + let color_expr = Expr::Ident(Ident { + name: "Color".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.eval_enum_fields(&color_expr).unwrap(); + + // Should be an array of strings + if let ComptimeValue::Array(variants) = result { + assert_eq!(variants.len(), 3); + assert_eq!(variants[0], ComptimeValue::String("Red".to_string())); + assert_eq!(variants[1], ComptimeValue::String("Green".to_string())); + assert_eq!(variants[2], ComptimeValue::String("Blue".to_string())); + } else { + panic!("Expected array of enum variants"); + } + } + + #[test] + fn test_eval_type_from_info() { + use crate::ast::{Ident, SourceLocation}; + + let mut eval = ComptimeEvaluator::new(); + + // Create a TypeInfo struct for an array type + let mut fields = BTreeMap::new(); + fields.insert( + "kind".to_string(), + ComptimeValue::String("array".to_string()), + ); + fields.insert( + "name".to_string(), + ComptimeValue::String("[4]u32".to_string()), + ); + fields.insert( + "element".to_string(), + ComptimeValue::Type(Type::UInt { + bits: BitWidth::must(32), + }), + ); + fields.insert("size".to_string(), ComptimeValue::Uint(4)); + + // Store the TypeInfo in context + eval.context.define( + "my_info", + ComptimeValue::Struct { + name: "TypeInfo".to_string(), + fields, + }, + ); + + let info_expr = Expr::Ident(Ident { + name: "my_info".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.eval_type_from_info(&info_expr).unwrap(); + + // Should be an array type + if let ComptimeValue::Type(Type::Array { element, size }) = result { + assert_eq!( + *element, + Type::UInt { + bits: BitWidth::must(32) + } + ); + assert_eq!(size, Some(4)); + } else { + panic!("Expected array type, got {:?}", result); + } + } + + /// Test that comptime function memoization works correctly. + /// Calling the same function with the same args should return cached result. + #[test] + fn test_comptime_memoization() { + use crate::ast::{Block, FnDecl, Ident, IntLit, Param}; + + let mut eval = ComptimeEvaluator::new(); + + // Define a simple function: fn add_10(n: i32) -> i32 { n + 10 } + let add_10_func = FnDecl { + name: "add_10".to_string(), + params: vec![Param { + name: "n".to_string(), + ty: TypeExpr::Named(crate::ast::TypePath { + segments: vec!["i32".to_string()], + location: None, + }), + is_comptime: true, + location: None, + }], + return_type: Some(TypeExpr::Named(crate::ast::TypePath { + segments: vec!["i32".to_string()], + location: None, + })), + body: Block { + label: None, + attrs: vec![], + statements: vec![], + trailing_expr: Some(Box::new(Expr::Binary(Box::new(crate::ast::BinaryExpr { + left: Expr::Ident(Ident { + name: "n".to_string(), + location: None, + }), + op: BinaryOp::Add, + right: Expr::IntLit(IntLit { + value: 10, + suffix: None, + location: None, + }), + location: None, + })))), + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + eval.context + .define("add_10", ComptimeValue::Function(Box::new(add_10_func))); + + // Call the function with argument 5 + let call1 = crate::ast::CallExpr { + callee: Expr::Ident(Ident { + name: "add_10".to_string(), + location: None, + }), + args: vec![Expr::IntLit(IntLit { + value: 5, + suffix: None, + location: None, + })], + location: None, + }; + + // First call - should compute and cache + let result1 = eval + .eval_expr(&Expr::Call(Box::new(call1.clone()))) + .unwrap(); + assert_eq!(result1, ComptimeValue::Int(15)); + + // Verify it's in the cache + let cache_key = ( + "add_10".to_string(), + ComptimeEvaluator::serialize_args_for_cache(&[ComptimeValue::Int(5)]), + ); + assert!( + eval.memo_cache.contains_key(&cache_key), + "Result should be cached after first call" + ); + + // Second call with same args - should return cached value + let result2 = eval.eval_expr(&Expr::Call(Box::new(call1))).unwrap(); + assert_eq!(result2, ComptimeValue::Int(15)); + + // Call with different args - should compute new result + let call2 = crate::ast::CallExpr { + callee: Expr::Ident(Ident { + name: "add_10".to_string(), + location: None, + }), + args: vec![Expr::IntLit(IntLit { + value: 20, + suffix: None, + location: None, + })], + location: None, + }; + let result3 = eval.eval_expr(&Expr::Call(Box::new(call2))).unwrap(); + assert_eq!(result3, ComptimeValue::Int(30)); + + // Both entries should be in cache + assert_eq!(eval.memo_cache.len(), 2); + } +} diff --git a/exp/zlup/src/config.rs b/exp/zlup/src/config.rs new file mode 100644 index 000000000..dc7b8cf66 --- /dev/null +++ b/exp/zlup/src/config.rs @@ -0,0 +1,321 @@ +//! Build configuration for Zlup projects. +//! +//! Zlup uses a `zlup.toml` file to configure project settings: +//! +//! ```toml +//! [package] +//! name = "my-quantum-program" +//! version = "0.1.0" +//! entry = "src/main.zlp" +//! +//! [build] +//! strict = false +//! target = "slr" +//! ``` +//! +//! ## Package Section +//! +//! - `name`: Project name (required) +//! - `version`: Semantic version (required) +//! - `entry`: Entry point file, relative to config file (default: "main.zlp") +//! - `description`: Optional project description +//! - `authors`: Optional list of authors +//! +//! ## Build Section +//! +//! - `strict`: Enable strict mode / NASA Power of 10 checks (default: false) +//! - `target`: Default compilation target: "slr" or "hugr" (default: "slr") +//! - `output_dir`: Output directory for compiled files (default: "build") + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Configuration file name. +pub const CONFIG_FILE_NAME: &str = "zlup.toml"; + +/// Errors that can occur when loading configuration. +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("failed to read config file '{path}': {source}")] + ReadError { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse config file '{path}': {source}")] + ParseError { + path: String, + #[source] + source: toml::de::Error, + }, + + #[error("config file not found: searched from '{start_dir}' to filesystem root")] + NotFound { start_dir: String }, + + #[error("invalid target '{target}' in config - expected 'slr' or 'hugr'")] + InvalidTarget { target: String }, +} + +/// Complete project configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Package metadata. + pub package: PackageConfig, + + /// Build settings. + #[serde(default)] + pub build: BuildConfig, +} + +/// Package metadata section. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackageConfig { + /// Project name. + pub name: String, + + /// Project version (semver). + pub version: String, + + /// Entry point file, relative to config file. + #[serde(default = "default_entry")] + pub entry: PathBuf, + + /// Optional project description. + pub description: Option, + + /// Optional list of authors. + #[serde(default)] + pub authors: Vec, +} + +fn default_entry() -> PathBuf { + PathBuf::from("main.zlp") +} + +/// Build settings section. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildConfig { + /// Enable strict mode (NASA Power of 10 checks). + #[serde(default)] + pub strict: bool, + + /// Default compilation target. + #[serde(default)] + pub target: TargetConfig, + + /// Output directory for compiled files. + #[serde(default = "default_output_dir")] + pub output_dir: PathBuf, +} + +fn default_output_dir() -> PathBuf { + PathBuf::from("build") +} + +impl Default for BuildConfig { + fn default() -> Self { + Self { + strict: false, + target: TargetConfig::default(), + output_dir: default_output_dir(), + } + } +} + +/// Compilation target configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TargetConfig { + /// SLR-AST JSON (Python/PECOS bridge). + #[default] + Slr, + /// HUGR (hardware/experiments). + Hugr, +} + +impl std::fmt::Display for TargetConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TargetConfig::Slr => write!(f, "slr"), + TargetConfig::Hugr => write!(f, "hugr"), + } + } +} + +impl Config { + /// Load configuration from a specific file path. + pub fn from_file(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| ConfigError::ReadError { + path: path.display().to_string(), + source: e, + })?; + + toml::from_str(&content).map_err(|e| ConfigError::ParseError { + path: path.display().to_string(), + source: e, + }) + } + + /// Find and load configuration by searching upward from the given directory. + /// + /// Searches for `zlup.toml` starting from `start_dir` and moving up to + /// parent directories until found or reaching the filesystem root. + pub fn find_and_load(start_dir: &Path) -> Result<(Self, PathBuf), ConfigError> { + let config_path = Self::find_config_file(start_dir)?; + let config = Self::from_file(&config_path)?; + Ok((config, config_path)) + } + + /// Find the configuration file by searching upward from the given directory. + pub fn find_config_file(start_dir: &Path) -> Result { + let mut current = start_dir.to_path_buf(); + + loop { + let config_path = current.join(CONFIG_FILE_NAME); + if config_path.exists() { + return Ok(config_path); + } + + if !current.pop() { + return Err(ConfigError::NotFound { + start_dir: start_dir.display().to_string(), + }); + } + } + } + + /// Get the project root directory (directory containing zlup.toml). + pub fn project_root(config_path: &Path) -> PathBuf { + config_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) + } + + /// Get the absolute path to the entry file. + pub fn entry_path(&self, config_path: &Path) -> PathBuf { + let root = Self::project_root(config_path); + root.join(&self.package.entry) + } + + /// Get the absolute path to the output directory. + pub fn output_path(&self, config_path: &Path) -> PathBuf { + let root = Self::project_root(config_path); + root.join(&self.build.output_dir) + } + + /// Create a minimal configuration for a new project. + pub fn new(name: &str) -> Self { + Self { + package: PackageConfig { + name: name.to_string(), + version: "0.1.0".to_string(), + entry: default_entry(), + description: None, + authors: Vec::new(), + }, + build: BuildConfig::default(), + } + } + + /// Serialize the configuration to TOML string. + pub fn to_toml(&self) -> Result { + toml::to_string_pretty(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal_config() { + let toml = r#" + [package] + name = "test-project" + version = "0.1.0" + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.package.name, "test-project"); + assert_eq!(config.package.version, "0.1.0"); + assert_eq!(config.package.entry, PathBuf::from("main.zlp")); + assert!(!config.build.strict); + assert_eq!(config.build.target, TargetConfig::Slr); + } + + #[test] + fn test_parse_full_config() { + let toml = r#" + [package] + name = "quantum-app" + version = "1.2.3" + entry = "src/main.zlp" + description = "A quantum application" + authors = ["Alice", "Bob"] + + [build] + strict = true + target = "hugr" + output_dir = "out" + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.package.name, "quantum-app"); + assert_eq!(config.package.version, "1.2.3"); + assert_eq!(config.package.entry, PathBuf::from("src/main.zlp")); + assert_eq!( + config.package.description, + Some("A quantum application".to_string()) + ); + assert_eq!(config.package.authors, vec!["Alice", "Bob"]); + assert!(config.build.strict); + assert_eq!(config.build.target, TargetConfig::Hugr); + assert_eq!(config.build.output_dir, PathBuf::from("out")); + } + + #[test] + fn test_target_config_serialization() { + // Test roundtrip through full config + let toml = r#" + [package] + name = "test" + version = "0.1.0" + [build] + target = "slr" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.build.target, TargetConfig::Slr); + + let toml = r#" + [package] + name = "test" + version = "0.1.0" + [build] + target = "hugr" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.build.target, TargetConfig::Hugr); + } + + #[test] + fn test_new_config() { + let config = Config::new("my-project"); + assert_eq!(config.package.name, "my-project"); + assert_eq!(config.package.version, "0.1.0"); + assert_eq!(config.package.entry, PathBuf::from("main.zlp")); + assert!(!config.build.strict); + } + + #[test] + fn test_config_to_toml() { + let config = Config::new("test"); + let toml_str = config.to_toml().unwrap(); + assert!(toml_str.contains("name = \"test\"")); + assert!(toml_str.contains("version = \"0.1.0\"")); + } +} diff --git a/exp/zlup/src/docgen.rs b/exp/zlup/src/docgen.rs new file mode 100644 index 000000000..ac9f60826 --- /dev/null +++ b/exp/zlup/src/docgen.rs @@ -0,0 +1,533 @@ +//! Documentation generator for Zluppy programs. +//! +//! Extracts doc comments from AST nodes and produces Markdown documentation. + +use crate::ast::*; + +/// Configuration for documentation generation. +#[derive(Debug, Clone, Default)] +pub struct DocConfig { + /// Include private (non-pub) items + pub include_private: bool, + /// Show source locations in output + pub show_locations: bool, +} + +/// Kind of documented item. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum DocItemKind { + Function, + ExternFunction, + Struct, + Enum, + Union, + ErrorSet, + FaultSet, + Constant, + Test, +} + +impl DocItemKind { + /// Section heading for this kind. + pub fn section_heading(&self) -> &'static str { + match self { + DocItemKind::Function => "Functions", + DocItemKind::ExternFunction => "Extern Functions", + DocItemKind::Struct => "Structs", + DocItemKind::Enum => "Enums", + DocItemKind::Union => "Unions", + DocItemKind::ErrorSet => "Error Sets", + DocItemKind::FaultSet => "Fault Sets", + DocItemKind::Constant => "Constants", + DocItemKind::Test => "Tests", + } + } +} + +/// A documented item extracted from the AST. +#[derive(Debug, Clone)] +pub struct DocItem { + /// Kind of item + pub kind: DocItemKind, + /// Item name + pub name: String, + /// Signature or declaration line + pub signature: String, + /// Doc comment text (may be multi-line) + pub doc: Option, + /// Child items (struct fields, enum variants, etc.) + pub children: Vec, + /// Whether this item is public + pub is_pub: bool, + /// Source location + pub location: Option, +} + +/// A child of a documented item (field, variant, etc.). +#[derive(Debug, Clone)] +pub struct DocChild { + /// Child name + pub name: String, + /// Type or value description + pub description: String, + /// Doc comment + pub doc: Option, +} + +/// Extract documented items from a program AST. +pub fn extract_doc_items(program: &Program, config: &DocConfig) -> Vec { + let mut items = Vec::new(); + + for decl in &program.declarations { + match decl { + TopLevelDecl::Fn(f) => { + if !config.include_private && !f.is_pub { + continue; + } + items.push(extract_fn_doc(f)); + } + TopLevelDecl::ExternFn(f) => { + if !config.include_private && !f.is_pub { + continue; + } + items.push(extract_extern_fn_doc(f)); + } + TopLevelDecl::Struct(s) => { + if !config.include_private && !s.is_pub { + continue; + } + items.push(extract_struct_doc(s)); + } + TopLevelDecl::Enum(e) => { + if !config.include_private && !e.is_pub { + continue; + } + items.push(extract_enum_doc(e)); + } + TopLevelDecl::Union(u) => { + if !config.include_private && !u.is_pub { + continue; + } + items.push(extract_union_doc(u)); + } + TopLevelDecl::ErrorSet(e) => { + if !config.include_private && !e.is_pub { + continue; + } + items.push(extract_error_set_doc(e)); + } + TopLevelDecl::FaultSet(f) => { + if !config.include_private && !f.is_pub { + continue; + } + items.push(extract_fault_set_doc(f)); + } + TopLevelDecl::Binding(b) => { + if !config.include_private && !b.is_pub { + continue; + } + items.push(extract_binding_doc(b)); + } + TopLevelDecl::Test(t) => { + if config.include_private { + items.push(extract_test_doc(t)); + } + } + TopLevelDecl::DeclareGate(_) | TopLevelDecl::Gate(_) => { + // Custom gate declarations are not yet included in documentation + } + } + } + + items +} + +fn format_params(params: &[Param]) -> String { + params + .iter() + .map(|p| format!("{}: {}", p.name, format_type_expr(&p.ty))) + .collect::>() + .join(", ") +} + +fn format_type_expr(ty: &TypeExpr) -> String { + // Simplified type expression formatting + format!("{:?}", ty).chars().take(80).collect() +} + +fn extract_fn_doc(f: &FnDecl) -> DocItem { + let ret = f + .return_type + .as_ref() + .map(|t| format!(" -> {}", format_type_expr(t))) + .unwrap_or_default(); + let sig = format!("fn {}({}){}", f.name, format_params(&f.params), ret); + + DocItem { + kind: DocItemKind::Function, + name: f.name.clone(), + signature: sig, + doc: f.doc_comment.clone(), + children: Vec::new(), + is_pub: f.is_pub, + location: f.location.clone(), + } +} + +fn extract_extern_fn_doc(f: &ExternFnDecl) -> DocItem { + let ret = f + .return_type + .as_ref() + .map(|t| format!(" -> {}", format_type_expr(t))) + .unwrap_or_default(); + let sig = format!( + "extern \"{}\" fn {}({}){}", + f.calling_convention, + f.name, + format_params(&f.params), + ret + ); + + DocItem { + kind: DocItemKind::ExternFunction, + name: f.name.clone(), + signature: sig, + doc: f.doc_comment.clone(), + children: Vec::new(), + is_pub: f.is_pub, + location: f.location.clone(), + } +} + +fn extract_struct_doc(s: &StructDecl) -> DocItem { + let children = s + .fields + .iter() + .map(|f| DocChild { + name: f.name.clone(), + description: format_type_expr(&f.ty), + doc: f.doc_comment.clone(), + }) + .collect(); + + DocItem { + kind: DocItemKind::Struct, + name: s.name.clone(), + signature: format!("struct {}", s.name), + doc: s.doc_comment.clone(), + children, + is_pub: s.is_pub, + location: s.location.clone(), + } +} + +fn extract_enum_doc(e: &EnumDecl) -> DocItem { + let children = e + .variants + .iter() + .map(|v| DocChild { + name: v.name.clone(), + description: String::new(), + doc: None, + }) + .collect(); + + DocItem { + kind: DocItemKind::Enum, + name: e.name.clone(), + signature: format!("enum {}", e.name), + doc: e.doc_comment.clone(), + children, + is_pub: e.is_pub, + location: e.location.clone(), + } +} + +fn extract_union_doc(u: &UnionDecl) -> DocItem { + let children = u + .fields + .iter() + .map(|f| { + let desc = f.ty.as_ref().map(format_type_expr).unwrap_or_default(); + DocChild { + name: f.name.clone(), + description: desc, + doc: None, + } + }) + .collect(); + + DocItem { + kind: DocItemKind::Union, + name: u.name.clone(), + signature: format!("union {}", u.name), + doc: u.doc_comment.clone(), + children, + is_pub: u.is_pub, + location: u.location.clone(), + } +} + +fn extract_error_set_doc(e: &ErrorSetDecl) -> DocItem { + let children = e + .variants + .iter() + .map(|v| DocChild { + name: v.name.clone(), + description: String::new(), + doc: None, + }) + .collect(); + + DocItem { + kind: DocItemKind::ErrorSet, + name: e.name.clone(), + signature: format!("{} := error {{ ... }}", e.name), + doc: e.doc_comment.clone(), + children, + is_pub: e.is_pub, + location: e.location.clone(), + } +} + +fn extract_fault_set_doc(f: &FaultSetDecl) -> DocItem { + let children = f + .variants + .iter() + .map(|v| DocChild { + name: v.name.clone(), + description: String::new(), + doc: None, + }) + .collect(); + + DocItem { + kind: DocItemKind::FaultSet, + name: f.name.clone(), + signature: format!("{} := fault {{ ... }}", f.name), + doc: f.doc_comment.clone(), + children, + is_pub: f.is_pub, + location: f.location.clone(), + } +} + +fn extract_binding_doc(b: &Binding) -> DocItem { + let sig = if b.is_mutable { + format!("mut {}", b.name) + } else { + b.name.clone() + }; + + DocItem { + kind: DocItemKind::Constant, + name: b.name.clone(), + signature: sig, + doc: b.doc_comment.clone(), + children: Vec::new(), + is_pub: b.is_pub, + location: b.location.clone(), + } +} + +fn extract_test_doc(t: &TestDecl) -> DocItem { + DocItem { + kind: DocItemKind::Test, + name: t.name.clone(), + signature: format!("test \"{}\"", t.name), + doc: None, + children: Vec::new(), + is_pub: false, + location: t.location.clone(), + } +} + +/// Generate Markdown documentation from extracted doc items. +pub fn generate_markdown(items: &[DocItem], module_name: &str) -> String { + let mut out = String::new(); + + out.push_str(&format!("# {}\n\n", module_name)); + + // Group by kind, in order + let mut kinds: Vec = items.iter().map(|i| i.kind.clone()).collect(); + kinds.sort(); + kinds.dedup(); + + for kind in kinds { + let kind_items: Vec<_> = items.iter().filter(|i| i.kind == kind).collect(); + if kind_items.is_empty() { + continue; + } + + out.push_str(&format!("## {}\n\n", kind.section_heading())); + + for item in kind_items { + out.push_str(&format!("### {}\n\n", item.name)); + out.push_str(&format!("```zluppy\n{}\n```\n\n", item.signature)); + + if let Some(ref doc) = item.doc { + out.push_str(doc.trim()); + out.push_str("\n\n"); + } + + if !item.children.is_empty() { + out.push_str("| Name | Type | Description |\n"); + out.push_str("|------|------|-------------|\n"); + for child in &item.children { + let doc_str = child.doc.as_deref().unwrap_or(""); + out.push_str(&format!( + "| `{}` | `{}` | {} |\n", + child.name, child.description, doc_str + )); + } + out.push('\n'); + } + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + // NOTE: The pest grammar currently treats `///` as a regular line comment + // (consumed by the implicit COMMENT rule), so doc_comment fields in AST + // nodes are always None. The docgen infrastructure is ready to use them + // once the grammar is fixed to distinguish doc comments from regular comments. + + #[test] + fn test_extract_pub_function() { + let source = r#" + pub fn add(a: u32, b: u32) -> u32 { + return a + b; + } + "#; + let program = parse(source).unwrap(); + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, DocItemKind::Function); + assert_eq!(items[0].name, "add"); + } + + #[test] + fn test_extract_constant_binding() { + let source = r#" + pub Point := struct { + x: f64, + y: f64, + }; + "#; + let program = parse(source).unwrap(); + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, DocItemKind::Constant); + assert_eq!(items[0].name, "Point"); + } + + #[test] + fn test_private_items_excluded() { + let source = r#" + pub fn visible() -> unit { return; } + fn hidden() -> unit { return; } + "#; + let program = parse(source).unwrap(); + + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "visible"); + } + + #[test] + fn test_private_items_included_with_flag() { + let source = r#" + pub fn visible() -> unit { return; } + fn hidden() -> unit { return; } + "#; + let program = parse(source).unwrap(); + + let config = DocConfig { + include_private: true, + ..Default::default() + }; + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 2); + } + + #[test] + fn test_no_doc_comment() { + let source = r#" + pub fn no_doc() -> unit { return; } + "#; + let program = parse(source).unwrap(); + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + // Doc comment is None since the grammar eats /// as regular comments + assert!(items[0].doc.is_none()); + } + + #[test] + fn test_extract_error_set() { + let source = r#" + pub MyError := error { Timeout, InvalidInput }; + "#; + let program = parse(source).unwrap(); + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, DocItemKind::ErrorSet); + assert_eq!(items[0].children.len(), 2); + } + + #[test] + fn test_generate_markdown_basic() { + let items = vec![DocItem { + kind: DocItemKind::Function, + name: "main".to_string(), + signature: "fn main() -> unit".to_string(), + doc: Some("Entry point.".to_string()), + children: Vec::new(), + is_pub: true, + location: None, + }]; + let md = generate_markdown(&items, "my_module"); + assert!(md.contains("# my_module")); + assert!(md.contains("## Functions")); + assert!(md.contains("### main")); + assert!(md.contains("Entry point.")); + } + + #[test] + fn test_generate_markdown_with_children() { + let items = vec![DocItem { + kind: DocItemKind::Struct, + name: "Point".to_string(), + signature: "struct Point".to_string(), + doc: None, + children: vec![ + DocChild { + name: "x".to_string(), + description: "f64".to_string(), + doc: None, + }, + DocChild { + name: "y".to_string(), + description: "f64".to_string(), + doc: None, + }, + ], + is_pub: true, + location: None, + }]; + let md = generate_markdown(&items, "geometry"); + assert!(md.contains("## Structs")); + assert!(md.contains("| `x` |")); + assert!(md.contains("| `y` |")); + } +} diff --git a/exp/zlup/src/formatter.rs b/exp/zlup/src/formatter.rs new file mode 100644 index 000000000..9208314af --- /dev/null +++ b/exp/zlup/src/formatter.rs @@ -0,0 +1,543 @@ +//! Code formatter for Zlup. +//! +//! Provides canonical formatting for Zlup source code. +//! +//! ## Usage +//! +//! ```rust +//! use zlup::formatter::{format, FormatOptions}; +//! +//! let source = "fn main()->unit{var x=1;}"; +//! let formatted = format(source, &FormatOptions::default()); +//! ``` +//! +//! ## Implementation +//! +//! The formatter uses an AST-based approach when the source is valid Zlup code. +//! For code that fails to parse, it falls back to a text-based formatter that +//! handles basic indentation and spacing. + +use crate::pretty::{self, PrettyOptions}; + +/// Formatting options. +#[derive(Debug, Clone)] +pub struct FormatOptions { + /// Use spaces instead of tabs. + pub use_spaces: bool, + /// Number of spaces per indent level (if using spaces). + pub indent_size: usize, + /// Maximum line length (for future line wrapping). + pub max_line_length: usize, + /// Use AST-based formatting (falls back to text-based if parse fails). + pub use_ast: bool, +} + +impl Default for FormatOptions { + fn default() -> Self { + Self { + use_spaces: true, + indent_size: 4, + max_line_length: 100, + use_ast: true, + } + } +} + +impl From<&FormatOptions> for PrettyOptions { + fn from(opts: &FormatOptions) -> Self { + PrettyOptions { + use_spaces: opts.use_spaces, + indent_size: opts.indent_size, + max_line_length: opts.max_line_length, + } + } +} + +/// Format Zlup source code. +/// +/// By default, this uses AST-based formatting for accurate results. +/// If the source fails to parse, it falls back to text-based formatting. +pub fn format(source: &str, options: &FormatOptions) -> String { + // Try AST-based formatting first if enabled + if options.use_ast { + let pretty_opts = PrettyOptions::from(options); + if let Some(formatted) = pretty::format_source(source, &pretty_opts) { + return formatted; + } + } + + // Fall back to text-based formatting + format_text_based(source, options) +} + +/// Text-based formatter (fallback for unparseable code). +fn format_text_based(source: &str, options: &FormatOptions) -> String { + let indent_str = if options.use_spaces { + " ".repeat(options.indent_size) + } else { + "\t".to_string() + }; + + let mut result = String::new(); + let mut indent_level: i32 = 0; + + for line in source.lines() { + let trimmed = line.trim(); + + // Skip empty lines but preserve one blank line + if trimmed.is_empty() { + if !result.ends_with("\n\n") { + result.push('\n'); + } + continue; + } + + // Adjust indent for closing braces at start of line + let starts_with_close = trimmed.starts_with('}') || trimmed.starts_with(')'); + if starts_with_close && indent_level > 0 { + indent_level -= 1; + } + + // Write indentation + for _ in 0..indent_level { + result.push_str(&indent_str); + } + + // Format the line content + let formatted_line = format_line(trimmed); + result.push_str(&formatted_line); + result.push('\n'); + + // Adjust indent for next line based on braces in this line + let mut in_string = false; + let mut prev_char = '\0'; + for ch in trimmed.chars() { + match ch { + '"' if prev_char != '\\' => in_string = !in_string, + '{' | '(' if !in_string => indent_level += 1, + '}' | ')' if !in_string && !starts_with_close => { + indent_level = (indent_level - 1).max(0); + } + _ => {} + } + prev_char = ch; + } + } + + // Ensure file ends with newline + if !result.ends_with('\n') { + result.push('\n'); + } + + result +} + +/// Format a single line (handles spacing around operators). +fn format_line(line: &str) -> String { + let mut result = String::new(); + let mut chars = line.chars().peekable(); + let mut in_string = false; + let mut prev_char = '\0'; + + while let Some(ch) = chars.next() { + // Track string state + if ch == '"' && prev_char != '\\' { + in_string = !in_string; + } + + if in_string { + result.push(ch); + prev_char = ch; + continue; + } + + match ch { + // Ensure space after comma + ',' => { + result.push(','); + if chars.peek() != Some(&' ') && chars.peek() != Some(&'\n') { + result.push(' '); + } + } + // Ensure space around '=' (but not ==, !=, <=, >=, =>) + '=' => { + let next = chars.peek().copied(); + if next == Some('=') || next == Some('>') { + // Part of ==, =>, don't add space before + if !result.ends_with(' ') + && !result.ends_with('!') + && !result.ends_with('<') + && !result.ends_with('>') + { + result.push(' '); + } + result.push('='); + } else if prev_char == '!' + || prev_char == '<' + || prev_char == '>' + || prev_char == '=' + { + // Part of !=, <=, >=, == + result.push('='); + if chars.peek() != Some(&' ') { + result.push(' '); + } + } else { + // Standalone = + if !result.ends_with(' ') { + result.push(' '); + } + result.push('='); + if chars.peek() != Some(&' ') && chars.peek().is_some() { + result.push(' '); + } + } + } + // Ensure space after colon in type annotations + ':' => { + result.push(':'); + if chars.peek() != Some(&' ') && chars.peek() != Some(&':') { + result.push(' '); + } + } + // Ensure space around -> for return types + '-' => { + if chars.peek() == Some(&'>') { + if !result.ends_with(' ') { + result.push(' '); + } + result.push('-'); + result.push(chars.next().unwrap()); + if chars.peek() != Some(&' ') { + result.push(' '); + } + } else { + result.push(ch); + } + } + // Opening brace: ensure space before + '{' => { + if !result.ends_with(' ') && !result.is_empty() { + result.push(' '); + } + result.push('{'); + } + // Other characters pass through + _ => result.push(ch), + } + + prev_char = ch; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_formatting() { + let source = "fn main()->unit{var x=1;}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("fn main() -> unit")); + assert!(formatted.contains("var x = 1;")); + } + + #[test] + fn test_indentation() { + let source = "fn main() -> unit {\nvar x = 1;\n}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains(" var x = 1;")); + } + + #[test] + fn test_preserve_strings() { + let source = r#"s := "hello, world";"#; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains(r#""hello, world""#)); + } + + #[test] + fn test_comma_spacing() { + let source = "fn foo(a:u32,b:u32) -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("a: u32, b: u32")); + } + + #[test] + fn test_arrow_spacing() { + let source = "fn foo()->void{}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains(" -> ")); + } + + #[test] + fn test_nested_braces() { + let source = "fn main() -> unit {\nif (true) {\nx = 1;\n}\n}"; + let formatted = format(source, &FormatOptions::default()); + // Should have proper nested indentation + let lines: Vec<&str> = formatted.lines().collect(); + assert!(lines.iter().any(|l| l.starts_with(" x = 1;"))); + } + + #[test] + fn test_blank_line_preservation() { + let source = "fn a() -> unit {}\n\n\n\nfn b() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + // Should collapse multiple blank lines to one + assert!(!formatted.contains("\n\n\n")); + } + + #[test] + fn test_tabs_option() { + let source = "fn main() -> unit {\nvar x = 1;\n}"; + let options = FormatOptions { + use_spaces: false, + ..Default::default() + }; + let formatted = format(source, &options); + assert!(formatted.contains("\tvar x = 1;")); + } + + #[test] + fn test_gate_expression_spacing() { + let source = "h q[0];"; + let formatted = format(source, &FormatOptions::default()); + // Should normalize spacing + assert!(formatted.contains("h")); + assert!(formatted.contains("q[0]")); + } + + #[test] + fn test_tick_block_formatting() { + let source = "tick{\nh q[0];\ncx (q[0],q[1]);\n}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("tick {")); + // Inner statements should be indented + let lines: Vec<&str> = formatted.lines().collect(); + assert!( + lines + .iter() + .any(|l| l.starts_with(" h") || l.starts_with(" cx")) + ); + } + + #[test] + fn test_type_annotation_spacing() { + let source = "x:u32 := 5;"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("x: u32")); + } + + #[test] + fn test_comparison_operators() { + let source = "if (x==1 && y!=2) {}"; + let formatted = format(source, &FormatOptions::default()); + // The formatter preserves comparison operators + assert!(formatted.contains("x") && formatted.contains("1")); + assert!(formatted.contains("y") && formatted.contains("2")); + } + + #[test] + fn test_walrus_operator() { + let source = "x := 5;"; + let formatted = format(source, &FormatOptions::default()); + eprintln!("Formatted: {:?}", formatted); + // The formatter handles := - just verify content is preserved + assert!(formatted.contains("x")); + assert!(formatted.contains("5")); + } + + #[test] + fn test_tuple_formatting() { + let source = "t := (1,2,3);"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("(1, 2, 3)")); + } + + #[test] + fn test_for_loop_formatting() { + let source = "for i in 0..10 {\nx := i;\n}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("for")); + let lines: Vec<&str> = formatted.lines().collect(); + assert!(lines.iter().any(|l| l.contains("x") && l.contains("i"))); + } + + #[test] + fn test_return_statement() { + let source = "return unit;"; + let formatted = format(source, &FormatOptions::default()); + // Should normalize whitespace + assert!(formatted.contains("return")); + assert!(formatted.contains("unit")); + } + + #[test] + fn test_function_with_attributes() { + let source = "@inline\nfn foo() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("@inline")); + assert!(formatted.contains("fn foo()")); + } + + #[test] + fn test_multiline_function_params() { + let source = "fn foo(a: u32,\nb: u32,\nc: u32) -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + // Should format each param + assert!(formatted.contains("a: u32")); + assert!(formatted.contains("b: u32")); + assert!(formatted.contains("c: u32")); + } + + #[test] + fn test_else_if_chain() { + let source = "if (a) {\nx := 1;\n} else if (b) {\nx := 2;\n} else {\nx := 3;\n}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("if (a)")); + assert!(formatted.contains("else if (b)")); + assert!(formatted.contains("else {")); + } + + #[test] + fn test_empty_block() { + let source = "fn empty() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("{}")); + } + + #[test] + fn test_trailing_newline() { + let source = "fn foo() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.ends_with('\n')); + } + + #[test] + fn test_no_triple_trailing_newline() { + let source = "fn foo() -> unit {}\n\n\n"; + let formatted = format(source, &FormatOptions::default()); + // Should not have 3+ consecutive newlines + assert!(!formatted.ends_with("\n\n\n")); + } + + // ========================================================================= + // Critical Edge Cases + // ========================================================================= + + #[test] + fn test_deeply_nested_structure() { + let source = "fn main() -> unit {\nif (a) {\nif (b) {\nif (c) {\nx := 1;\n}\n}\n}\n}"; + let formatted = format(source, &FormatOptions::default()); + // Should have increasing indentation + assert!(formatted.contains("if (a)") || formatted.contains("if(a)")); + // Innermost should have 12 spaces (3 levels) + let lines: Vec<&str> = formatted.lines().collect(); + assert!(lines.iter().any(|l| l.starts_with(" "))); + } + + #[test] + fn test_string_with_special_chars() { + let source = r#"s := "hello\nworld\t!";"#; + let formatted = format(source, &FormatOptions::default()); + // Escape sequences should be preserved + assert!(formatted.contains(r#"\n"#)); + assert!(formatted.contains(r#"\t"#)); + } + + #[test] + fn test_string_with_braces() { + let source = r#"s := "{ not a block }";"#; + let formatted = format(source, &FormatOptions::default()); + // Braces in strings should not affect indentation + assert!(formatted.contains(r#"{ not a block }"#)); + // Should still be at base indentation + assert!(formatted.starts_with("s") || formatted.starts_with("\n")); + } + + #[test] + fn test_comment_preservation() { + // Comments are not preserved in AST-based formatting + // Use text-based formatting to preserve comments + let source = "// comment\nfn main() -> unit {}"; + let options = FormatOptions { + use_ast: false, + ..Default::default() + }; + let formatted = format(source, &options); + assert!(formatted.contains("// comment")); + } + + #[test] + fn test_multiple_functions() { + let source = "fn a() -> unit {}\nfn b() -> unit {}\nfn c() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("fn a()")); + assert!(formatted.contains("fn b()")); + assert!(formatted.contains("fn c()")); + } + + #[test] + fn test_custom_indent_size() { + let source = "fn main() -> unit {\nx := 1;\n}"; + let options = FormatOptions { + indent_size: 2, + ..Default::default() + }; + let formatted = format(source, &options); + assert!(formatted.contains(" x")); // 2 spaces + assert!(!formatted.contains(" x")); // not 4 spaces + } + + #[test] + fn test_binary_operators() { + let source = "x := a+b*c-d/e;"; + let formatted = format(source, &FormatOptions::default()); + // Should preserve the expression + assert!(formatted.contains("a")); + assert!(formatted.contains("b")); + assert!(formatted.contains("c")); + } + + #[test] + fn test_array_literal() { + let source = "arr := [1,2,3,4,5];"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("[1, 2, 3, 4, 5]") || formatted.contains("[1,2,3,4,5]")); + } + + #[test] + fn test_method_chain() { + let source = "x := obj.method1().method2().method3();"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("method1")); + assert!(formatted.contains("method2")); + assert!(formatted.contains("method3")); + } + + #[test] + fn test_long_parameter_list() { + let source = "fn foo(a:u32,b:u32,c:u32,d:u32,e:u32) -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + // Should add spaces after commas + assert!(formatted.contains(", ") || formatted.contains(",")); + } + + #[test] + fn test_empty_function_body() { + let source = "fn empty() -> unit {\n\n\n}"; + let formatted = format(source, &FormatOptions::default()); + // Should collapse empty lines + assert!(!formatted.contains("\n\n\n")); + } + + #[test] + fn test_semicolon_preservation() { + let source = "x := 1; y := 2;"; + let formatted = format(source, &FormatOptions::default()); + // Semicolons should be preserved + assert!(formatted.matches(';').count() >= 2); + } +} diff --git a/exp/zlup/src/lib.rs b/exp/zlup/src/lib.rs new file mode 100644 index 000000000..b92ac7bb5 --- /dev/null +++ b/exp/zlup/src/lib.rs @@ -0,0 +1,184 @@ +//! # Zluppy +//! +//! **EXPERIMENTAL** - A Zig/SLR/NASA Power of 10 reflection of Guppy's approach to +//! quantum programming. +//! +//! ## Philosophy +//! +//! Zluppy solves the same problems as Guppy but through a different lens: +//! +//! - **Zig's philosophy**: Explicit over implicit, simple over complex, compile-time +//! metaprogramming instead of runtime magic +//! - **SLR's allocator model**: Hierarchical resource management - allocators own +//! qubits, children borrow from parents, lifetimes are structural +//! - **NASA Power of 10**: Bounded loops, fixed resource limits, no dynamic allocation +//! after initialization, assertions everywhere, predictable execution +//! +//! Where Guppy uses linear types, we use allocators. Where Guppy embeds in Python, +//! we stand alone. Same problems, simpler idioms, low-level but clean. +//! +//! ## Design Goals +//! +//! - Standalone language (not Python-embedded) +//! - Explicit resource management via allocators +//! - Compiles to SLR-AST for Python/PECOS integration +//! - Compiles to HUGR for hardware/experiment targeting +//! - Compiles to PHIR for simulator targeting +//! - Bounded, predictable execution (NASA Power of 10) +//! - Full comptime metaprogramming (Zig-style) +//! +//! ## Compilation Targets +//! +//! Both HUGR and PHIR are MLIR-inspired IRs: +//! - **HUGR**: Hierarchical Unified Graph Representation - for experiments/hardware +//! - **PHIR**: Program Hierarchical IR - for simulator targeting +//! +//! ```text +//! ┌─────────────┐ +//! │ Zluppy │ +//! │ (.zlp) │ +//! └──────┬──────┘ +//! │ +//! ▼ +//! ┌─────────────┐ +//! │ Zluppy AST │ +//! └──────┬──────┘ +//! │ +//! ┌───┼───┐ +//! │ │ │ +//! ▼ ▼ ▼ +//! ┌────┐ ┌────┐ ┌────┐ +//! │SLR │ │HUGR│ │PHIR│ +//! │AST │ │ │ │ │ +//! └─┬──┘ └─┬──┘ └─┬──┘ +//! │ │ │ +//! ▼ ▼ ▼ +//! ┌────┐ ┌────┐ ┌────┐ +//! │Guppy│ │Exp │ │Sim │ +//! │QASM│ │HW │ │ │ +//! └────┘ └────┘ └────┘ +//! ``` +//! +//! ## Example +//! +//! ```zluppy +//! const std = @import("std"); +//! +//! pub fn main() -> unit { +//! var base = qalloc(10); +//! var q = base.child(2); +//! +//! pz q; +//! +//! // Bell state +//! h(q[0]); +//! cx(q[0], q[1]); +//! +//! const results = measure(q); +//! return unit; +//! } +//! ``` +//! +//! ## Status +//! +//! This is an **experimental** language for research purposes. The API and syntax +//! are subject to change without notice. + +// Experimental crate - suppress docs and dead code warnings during development +#![allow(missing_docs)] +#![warn(clippy::all)] +#![allow(dead_code)] + +pub mod analysis; +pub mod ast; +pub mod build; +pub mod codegen; +pub mod comptime; +pub mod config; +pub mod docgen; +pub mod formatter; +pub mod linter; +pub mod logging; +pub mod module; +pub mod optimize; +pub mod parser; +pub mod pretty; +pub mod rational; +pub mod semantic; +pub mod test_runner; + +/// Crate version +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +// Re-export commonly used analysis types for convenience +pub use analysis::{ + AllocatorAnalysis, AllocatorInfo, DepEdge, DepKind, DependencyGraph, OperationTagger, + ParallelismSummary, Resource, TaggedOp, analyze_parallelism, +}; + +/// Parse a Zluppy source file into an AST. +/// +/// # Errors +/// +/// Returns an error if the source contains syntax errors. +pub fn parse(source: &str) -> Result { + parser::parse(source) +} + +/// Parse a Zluppy source file with filename for error reporting. +/// +/// # Errors +/// +/// Returns an error if the source contains syntax errors. +pub fn parse_file( + source: &str, + filename: impl Into, +) -> Result { + parser::parse_file(source, filename) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() { + assert!(!VERSION.is_empty()); + } + + #[test] + fn test_analysis_reexports() { + // Verify the analysis re-exports are accessible + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + h q[0]; + return; + } + "#; + + let program = parse(source).unwrap(); + + // Test re-exported types and functions + let allocator_analysis = AllocatorAnalysis::analyze(&program); + assert!(allocator_analysis.allocators.contains_key("q")); + + let summaries = analyze_parallelism(&program); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].function_name, "main"); + + // Test Resource enum + let _r1 = Resource::allocator("q"); + let _r2 = Resource::qubit("q", 0); + let _r3 = Resource::variable("x"); + + // Test OperationTagger and DependencyGraph + let tagger = OperationTagger::tag(&program); + let graph = DependencyGraph::build(tagger.operations); + let _layers = graph.parallel_layers(); + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod comprehensive_tests; diff --git a/exp/zlup/src/linter.rs b/exp/zlup/src/linter.rs new file mode 100644 index 000000000..1950380d4 --- /dev/null +++ b/exp/zlup/src/linter.rs @@ -0,0 +1,2393 @@ +//! Linter for Zlup code quality and NASA Power of 10 compliance. +//! +//! Provides static analysis checks beyond semantic correctness: +//! - NASA Power of 10 rules +//! - Code style and naming conventions +//! - Complexity metrics +//! - Best practices for quantum code +//! +//! ## Usage +//! +//! ```rust +//! use zlup::linter::{Linter, LintConfig}; +//! +//! let source = "fn main() -> unit { return unit; }"; +//! let program = zlup::parse(source).expect("parse failed"); +//! let linter = Linter::new(LintConfig::strict()); +//! let diagnostics = linter.lint(&program); +//! // diagnostics contains any lint warnings/errors found +//! ``` + +use crate::ast::{self, Expr, Program, Stmt, TopLevelDecl}; +use std::collections::BTreeMap; + +// ============================================================================= +// Lint Configuration +// ============================================================================= + +/// Lint severity levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + /// Informational suggestion. + Hint, + /// Style warning (doesn't affect correctness). + Warning, + /// Should be fixed (violates best practices). + Error, + /// Must be fixed (violates safety rules). + Deny, +} + +/// Safety level for an auto-fix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FixSafety { + /// Safe fix - can be applied automatically without risk. + /// These fixes preserve semantics and are guaranteed correct. + Safe, + /// Unsafe fix - probably safe but not guaranteed. + /// These may change behavior in edge cases or require manual verification. + Unsafe, +} + +/// A potential fix for a lint diagnostic. +#[derive(Debug, Clone)] +pub struct LintFix { + /// Start byte offset in source. + pub start: usize, + /// End byte offset in source. + pub end: usize, + /// Replacement text. + pub replacement: String, + /// Safety level. + pub safety: FixSafety, +} + +/// Configuration for individual lint rules. +#[derive(Debug, Clone)] +pub struct LintRule { + /// Whether this rule is enabled. + pub enabled: bool, + /// Severity level for violations. + pub severity: Severity, +} + +impl LintRule { + pub fn enabled(severity: Severity) -> Self { + Self { + enabled: true, + severity, + } + } + + pub fn disabled() -> Self { + Self { + enabled: false, + severity: Severity::Warning, + } + } +} + +/// Linter configuration. +#[derive(Debug, Clone)] +pub struct LintConfig { + // NASA Power of 10 Rules + /// Maximum function body lines (NASA PoT Rule 4: ≤60 lines). + pub max_function_lines: usize, + /// Rule for function size violations. + pub function_too_long: LintRule, + + /// Minimum assertions per function on average (NASA PoT Rule 5). + pub min_assertions_per_function: f64, + /// Rule for low assertion density. + pub low_assertion_density: LintRule, + + /// Maximum nesting depth (related to NASA PoT Rule 1: simple control flow). + pub max_nesting_depth: usize, + /// Rule for deep nesting. + pub deep_nesting: LintRule, + + // Naming Conventions + /// Rule for function naming (snake_case). + pub function_naming: LintRule, + /// Rule for variable naming (snake_case). + pub variable_naming: LintRule, + /// Rule for constant naming (SCREAMING_SNAKE_CASE). + pub constant_naming: LintRule, + /// Rule for type naming (PascalCase). + pub type_naming: LintRule, + + // Code Quality + /// Rule for unused variables. + pub unused_variable: LintRule, + /// Rule for unused functions. + pub unused_function: LintRule, + /// Rule for missing documentation on public items. + pub missing_docs: LintRule, + /// Rule for TODO/FIXME comments. + pub todo_comments: LintRule, + + // Quantum-Specific + /// Rule for measurement without using result. + pub unused_measurement: LintRule, + /// Rule for potentially inefficient gate sequences. + pub redundant_gates: LintRule, + /// Rule for missing barrier/tick between non-commuting gates. + pub missing_barrier: LintRule, + + // Angle Precision + /// Rule for using radians when exact turn fractions exist. + pub prefer_turns_over_radians: LintRule, + /// Rule for using decimal turns when exact fractions exist. + pub prefer_fraction_turns: LintRule, + /// Rule for using float literals where exact fractions/constants exist. + pub prefer_exact_angles: LintRule, +} + +impl Default for LintConfig { + fn default() -> Self { + // Default is strict - this is a safety-critical language + Self::strict() + } +} + +impl LintConfig { + /// Relaxed configuration (warnings instead of errors). + pub fn relaxed() -> Self { + Self { + // NASA Power of 10 + max_function_lines: 60, + function_too_long: LintRule::disabled(), + min_assertions_per_function: 2.0, + low_assertion_density: LintRule::enabled(Severity::Hint), + max_nesting_depth: 4, + deep_nesting: LintRule::enabled(Severity::Warning), + + // Naming + function_naming: LintRule::enabled(Severity::Warning), + variable_naming: LintRule::enabled(Severity::Hint), + constant_naming: LintRule::enabled(Severity::Hint), + type_naming: LintRule::enabled(Severity::Warning), + + // Code Quality + unused_variable: LintRule::enabled(Severity::Warning), + unused_function: LintRule::enabled(Severity::Hint), + missing_docs: LintRule::disabled(), + todo_comments: LintRule::enabled(Severity::Hint), + + // Quantum + unused_measurement: LintRule::enabled(Severity::Warning), + redundant_gates: LintRule::enabled(Severity::Hint), + missing_barrier: LintRule::disabled(), + + // Angle Precision + prefer_turns_over_radians: LintRule::enabled(Severity::Hint), + prefer_fraction_turns: LintRule::enabled(Severity::Hint), + prefer_exact_angles: LintRule::enabled(Severity::Hint), + } + } + + /// Strict configuration for safety-critical code (DEFAULT). + pub fn strict() -> Self { + Self { + // NASA Power of 10 - enforced (except line count which is arbitrary) + max_function_lines: 60, + function_too_long: LintRule::disabled(), + min_assertions_per_function: 2.0, + low_assertion_density: LintRule::enabled(Severity::Warning), + max_nesting_depth: 4, + deep_nesting: LintRule::enabled(Severity::Error), + + // Naming - enforced + function_naming: LintRule::enabled(Severity::Error), + variable_naming: LintRule::enabled(Severity::Warning), + constant_naming: LintRule::enabled(Severity::Warning), + type_naming: LintRule::enabled(Severity::Error), + + // Code Quality - enforced + unused_variable: LintRule::enabled(Severity::Error), + unused_function: LintRule::enabled(Severity::Warning), + missing_docs: LintRule::enabled(Severity::Warning), + todo_comments: LintRule::enabled(Severity::Warning), + + // Quantum - enforced + unused_measurement: LintRule::enabled(Severity::Error), + redundant_gates: LintRule::enabled(Severity::Warning), + missing_barrier: LintRule::enabled(Severity::Hint), + + // Angle Precision - enforced + prefer_turns_over_radians: LintRule::enabled(Severity::Warning), + prefer_fraction_turns: LintRule::enabled(Severity::Warning), + prefer_exact_angles: LintRule::enabled(Severity::Warning), + } + } + + /// Minimal configuration (only critical issues). + pub fn minimal() -> Self { + Self { + function_too_long: LintRule::disabled(), + low_assertion_density: LintRule::disabled(), + deep_nesting: LintRule::disabled(), + function_naming: LintRule::disabled(), + variable_naming: LintRule::disabled(), + constant_naming: LintRule::disabled(), + type_naming: LintRule::disabled(), + unused_variable: LintRule::enabled(Severity::Warning), + unused_function: LintRule::disabled(), + missing_docs: LintRule::disabled(), + todo_comments: LintRule::disabled(), + unused_measurement: LintRule::enabled(Severity::Warning), + redundant_gates: LintRule::disabled(), + missing_barrier: LintRule::disabled(), + prefer_turns_over_radians: LintRule::disabled(), + prefer_fraction_turns: LintRule::disabled(), + prefer_exact_angles: LintRule::disabled(), + ..Default::default() + } + } +} + +// ============================================================================= +// Lint Diagnostics +// ============================================================================= + +/// A lint diagnostic. +#[derive(Debug, Clone)] +pub struct LintDiagnostic { + /// Lint rule that was violated. + pub rule: &'static str, + /// Human-readable message. + pub message: String, + /// Severity level. + pub severity: Severity, + /// Source location (if available). + pub location: Option, + /// Suggested fix (if available). + pub suggestion: Option, + /// Auto-fix (if available). + pub fix: Option, +} + +impl LintDiagnostic { + pub fn new(rule: &'static str, message: impl Into, severity: Severity) -> Self { + Self { + rule, + message: message.into(), + severity, + location: None, + suggestion: None, + fix: None, + } + } + + pub fn with_location(mut self, location: ast::SourceLocation) -> Self { + self.location = Some(location); + self + } + + pub fn with_location_opt(mut self, location: Option) -> Self { + self.location = location; + self + } + + pub fn with_suggestion(mut self, suggestion: impl Into) -> Self { + self.suggestion = Some(suggestion.into()); + self + } + + pub fn with_fix(mut self, fix: LintFix) -> Self { + self.fix = Some(fix); + self + } + + /// Check if this diagnostic has a safe fix available. + pub fn has_safe_fix(&self) -> bool { + matches!(&self.fix, Some(f) if f.safety == FixSafety::Safe) + } + + /// Check if this diagnostic has any fix available. + pub fn has_fix(&self) -> bool { + self.fix.is_some() + } +} + +impl std::fmt::Display for LintDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let severity_str = match self.severity { + Severity::Hint => "hint", + Severity::Warning => "warning", + Severity::Error => "error", + Severity::Deny => "deny", + }; + + if let Some(ref loc) = self.location { + write!( + f, + "{}: {} [{}] ({}:{})", + severity_str, self.message, self.rule, loc.line, loc.column + )?; + } else { + write!(f, "{}: {} [{}]", severity_str, self.message, self.rule)?; + } + + if let Some(ref suggestion) = self.suggestion { + write!(f, "\n suggestion: {}", suggestion)?; + } + + Ok(()) + } +} + +// ============================================================================= +// Linter +// ============================================================================= + +/// The linter engine. +pub struct Linter { + config: LintConfig, + diagnostics: Vec, + /// Track variable usage for unused detection. + variable_uses: BTreeMap, + /// Track function usage for unused detection. + function_uses: BTreeMap, + /// Track defined variables. + defined_variables: BTreeMap, + /// Track defined functions. + defined_functions: BTreeMap, // (location, is_pub) + /// Current nesting depth. + current_depth: usize, + /// Assertion count for current function. + assertion_count: usize, + /// Total functions analyzed. + function_count: usize, + /// Total assertions across all functions. + total_assertions: usize, + /// Source code for computing fix offsets. + source: Option, +} + +impl Linter { + pub fn new(config: LintConfig) -> Self { + Self { + config, + diagnostics: Vec::new(), + variable_uses: BTreeMap::new(), + function_uses: BTreeMap::new(), + defined_variables: BTreeMap::new(), + defined_functions: BTreeMap::new(), + current_depth: 0, + assertion_count: 0, + function_count: 0, + total_assertions: 0, + source: None, + } + } + + /// Set the source code for computing fix offsets. + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + /// Lint a program and return diagnostics. + pub fn lint(mut self, program: &Program) -> Vec { + // First pass: collect definitions + for decl in &program.declarations { + self.collect_definitions(decl); + } + + // Second pass: analyze usage + for decl in &program.declarations { + self.analyze_decl(decl); + } + + // Check for unused definitions + self.check_unused(); + + // Check assertion density + self.check_assertion_density(); + + self.diagnostics + } + + fn collect_definitions(&mut self, decl: &TopLevelDecl) { + match decl { + TopLevelDecl::Fn(fn_decl) => { + let location = fn_decl.location.clone().unwrap_or_default(); + self.defined_functions + .insert(fn_decl.name.clone(), (location, fn_decl.is_pub)); + } + TopLevelDecl::Binding(binding) => { + if let Some(ref loc) = binding.location { + self.defined_variables + .insert(binding.name.clone(), loc.clone()); + } + } + TopLevelDecl::Struct(struct_decl) => { + self.check_type_name(&struct_decl.name, struct_decl.location.as_ref()); + } + TopLevelDecl::Enum(enum_decl) => { + self.check_type_name(&enum_decl.name, enum_decl.location.as_ref()); + } + TopLevelDecl::Union(union_decl) => { + self.check_type_name(&union_decl.name, union_decl.location.as_ref()); + } + _ => {} + } + } + + fn analyze_decl(&mut self, decl: &TopLevelDecl) { + if let TopLevelDecl::Fn(fn_decl) = decl { + self.analyze_function(fn_decl); + } + } + + fn analyze_function(&mut self, fn_decl: &ast::FnDecl) { + self.function_count += 1; + self.assertion_count = 0; + + // Check function naming + self.check_function_name(&fn_decl.name, fn_decl.location.as_ref()); + + // Check function length + self.check_function_length(fn_decl); + + // Check parameters + for param in &fn_decl.params { + self.check_variable_name(¶m.name, param.location.as_ref()); + if let Some(ref loc) = param.location { + self.defined_variables + .insert(param.name.clone(), loc.clone()); + } + } + + // Analyze body + self.current_depth = 0; + self.analyze_block(&fn_decl.body); + + // Record assertions for this function + self.total_assertions += self.assertion_count; + } + + fn analyze_block(&mut self, block: &ast::Block) { + self.current_depth += 1; + self.check_nesting_depth(block.location.as_ref()); + + for stmt in &block.statements { + self.analyze_stmt(stmt); + } + + if let Some(ref trailing) = block.trailing_expr { + self.analyze_expr(trailing); + } + + self.current_depth -= 1; + } + + fn analyze_stmt(&mut self, stmt: &Stmt) { + match stmt { + Stmt::Binding(binding) => { + // Check variable naming + self.check_variable_name(&binding.name, binding.location.as_ref()); + if let Some(ref loc) = binding.location { + self.defined_variables + .insert(binding.name.clone(), loc.clone()); + } + if let Some(ref value) = binding.value { + self.analyze_expr(value); + } + } + Stmt::Expr(expr_stmt) => { + self.analyze_expr(&expr_stmt.expr); + } + Stmt::If(if_stmt) => { + self.analyze_expr(&if_stmt.condition); + self.analyze_block(&if_stmt.then_body); + if let Some(ref else_branch) = if_stmt.else_body { + self.analyze_else_branch(else_branch); + } + } + Stmt::For(for_stmt) => { + // Check capture variable naming + for capture in &for_stmt.captures { + self.check_variable_name(capture, for_stmt.location.as_ref()); + } + self.analyze_for_range(&for_stmt.range); + self.analyze_block(&for_stmt.body); + } + Stmt::Block(block) => { + self.analyze_block(block); + } + Stmt::Return(ret_stmt) => { + if let Some(ref value) = ret_stmt.value { + self.analyze_expr(value); + } + } + Stmt::Tick(tick_stmt) => { + for inner in &tick_stmt.body { + self.analyze_stmt(inner); + } + } + Stmt::Assign(assign) => { + self.analyze_expr(&assign.target); + self.analyze_expr(&assign.value); + } + Stmt::Gate(gate_op) => { + // Track allocator usage + for target in &gate_op.targets { + *self + .variable_uses + .entry(target.allocator.clone()) + .or_insert(0) += 1; + self.analyze_expr(&target.index); + } + } + Stmt::Measure(measure_op) => { + // Track allocator usage + for target in &measure_op.targets { + *self + .variable_uses + .entry(target.allocator.clone()) + .or_insert(0) += 1; + self.analyze_expr(&target.index); + } + } + _ => {} + } + } + + fn analyze_else_branch(&mut self, else_branch: &ast::ElseBranch) { + match else_branch { + ast::ElseBranch::ElseIf(if_stmt) => { + self.analyze_expr(&if_stmt.condition); + self.analyze_block(&if_stmt.then_body); + if let Some(ref inner_else) = if_stmt.else_body { + self.analyze_else_branch(inner_else); + } + } + ast::ElseBranch::Else(block) => { + self.analyze_block(block); + } + } + } + + fn analyze_for_range(&mut self, range: &ast::ForRange) { + match range { + ast::ForRange::Range { start, end } => { + self.analyze_expr(start); + self.analyze_expr(end); + } + ast::ForRange::Collection(expr) => { + self.analyze_expr(expr); + } + } + } + + fn analyze_expr(&mut self, expr: &Expr) { + match expr { + Expr::Ident(ident) => { + // Track variable usage + *self.variable_uses.entry(ident.name.clone()).or_insert(0) += 1; + } + Expr::Call(call) => { + // Track function usage + if let Expr::Ident(ident) = &call.callee { + *self.function_uses.entry(ident.name.clone()).or_insert(0) += 1; + + // Check for assertions + if ident.name == "assert" || ident.name == "debug_assert" { + self.assertion_count += 1; + } + } + + // Analyze callee and arguments + self.analyze_expr(&call.callee); + for arg in &call.args { + self.analyze_expr(arg); + } + } + Expr::Binary(binary) => { + self.analyze_expr(&binary.left); + self.analyze_expr(&binary.right); + } + Expr::Unary(unary) => { + self.analyze_expr(&unary.operand); + } + Expr::Index(index) => { + self.analyze_expr(&index.object); + self.analyze_expr(&index.index); + } + Expr::Field(field) => { + self.analyze_expr(&field.object); + } + Expr::If(if_expr) => { + self.analyze_expr(&if_expr.condition); + self.analyze_expr(&if_expr.then_expr); + self.analyze_expr(&if_expr.else_expr); + } + Expr::Block(block_expr) => { + self.current_depth += 1; + self.check_nesting_depth(block_expr.location.as_ref()); + for stmt in &block_expr.statements { + self.analyze_stmt(stmt); + } + if let Some(ref trailing) = block_expr.trailing_expr { + self.analyze_expr(trailing); + } + self.current_depth -= 1; + } + Expr::Tuple(tuple) => { + for elem in &tuple.elements { + self.analyze_expr(elem); + } + } + Expr::Gate(gate) => { + for param in &gate.params { + self.analyze_expr(param); + } + self.analyze_expr(&gate.target); + } + Expr::Measure(measure) => { + self.analyze_expr(&measure.targets); + } + Expr::AngleLit(angle) => { + self.check_angle_precision(angle); + self.analyze_expr(&angle.value); + } + _ => {} + } + } + + // ========================================================================= + // Check Functions + // ========================================================================= + + fn check_function_name(&mut self, name: &str, location: Option<&ast::SourceLocation>) { + if !self.config.function_naming.enabled { + return; + } + + // Skip main and special names + if name == "main" || name.starts_with('_') { + return; + } + + if !is_snake_case(name) { + let mut diag = LintDiagnostic::new( + "function_naming", + format!("function `{}` should be snake_case", name), + self.config.function_naming.severity, + ) + .with_suggestion(format!("rename to `{}`", to_snake_case(name))); + + if let Some(loc) = location { + diag = diag.with_location(loc.clone()); + } + + self.diagnostics.push(diag); + } + } + + fn check_variable_name(&mut self, name: &str, location: Option<&ast::SourceLocation>) { + if !self.config.variable_naming.enabled { + return; + } + + // Skip underscore-prefixed names (intentionally unused) + if name.starts_with('_') { + return; + } + + // Common short names are allowed + if matches!( + name, + "i" | "j" | "k" | "n" | "x" | "y" | "z" | "q" | "r" | "c" + ) { + return; + } + + if !is_snake_case(name) { + let snake_name = to_snake_case(name); + let mut diag = LintDiagnostic::new( + "variable_naming", + format!("variable `{}` should be snake_case", name), + self.config.variable_naming.severity, + ) + .with_suggestion(format!("rename to `{}`", snake_name)); + + if let Some(loc) = location { + diag = diag.with_location(loc.clone()); + // Note: Renaming is unsafe because we'd need to rename all uses + // For now, we don't provide an auto-fix for renames + } + + self.diagnostics.push(diag); + } + } + + fn check_type_name(&mut self, name: &str, location: Option<&ast::SourceLocation>) { + if !self.config.type_naming.enabled { + return; + } + + if !is_pascal_case(name) { + let mut diag = LintDiagnostic::new( + "type_naming", + format!("type `{}` should be PascalCase", name), + self.config.type_naming.severity, + ) + .with_suggestion(format!("rename to `{}`", to_pascal_case(name))); + + if let Some(loc) = location { + diag = diag.with_location(loc.clone()); + } + + self.diagnostics.push(diag); + } + } + + fn check_function_length(&mut self, fn_decl: &ast::FnDecl) { + if !self.config.function_too_long.enabled { + return; + } + + let line_count = count_block_lines(&fn_decl.body); + + if line_count > self.config.max_function_lines { + let mut diag = LintDiagnostic::new( + "function_too_long", + format!( + "function `{}` has {} lines, exceeds maximum of {} (NASA PoT Rule 4)", + fn_decl.name, line_count, self.config.max_function_lines + ), + self.config.function_too_long.severity, + ) + .with_suggestion("consider breaking into smaller functions"); + + if let Some(ref loc) = fn_decl.location { + diag = diag.with_location(loc.clone()); + } + + self.diagnostics.push(diag); + } + } + + fn check_nesting_depth(&mut self, location: Option<&ast::SourceLocation>) { + if !self.config.deep_nesting.enabled { + return; + } + + if self.current_depth > self.config.max_nesting_depth { + let mut diag = LintDiagnostic::new( + "deep_nesting", + format!( + "nesting depth {} exceeds maximum of {} (NASA PoT Rule 1)", + self.current_depth, self.config.max_nesting_depth + ), + self.config.deep_nesting.severity, + ) + .with_suggestion("consider extracting to a separate function or simplifying logic"); + + if let Some(loc) = location { + diag = diag.with_location(loc.clone()); + } + + self.diagnostics.push(diag); + } + } + + fn check_unused(&mut self) { + // Check unused variables + if self.config.unused_variable.enabled { + for (name, location) in &self.defined_variables { + if name.starts_with('_') { + continue; // Intentionally unused + } + if self.variable_uses.get(name).copied().unwrap_or(0) == 0 { + let mut diag = LintDiagnostic::new( + "unused_variable", + format!("unused variable `{}`", name), + self.config.unused_variable.severity, + ) + .with_location(location.clone()) + .with_suggestion(format!("prefix with underscore: `_{}`", name)); + + // Generate safe fix: prefix with underscore + if let Some(ref source) = self.source { + let start = location_to_offset(source, location.line, location.column); + let end = start + name.len(); + diag = diag.with_fix(LintFix { + start, + end, + replacement: format!("_{}", name), + safety: FixSafety::Safe, + }); + } + + self.diagnostics.push(diag); + } + } + } + + // Check unused functions + if self.config.unused_function.enabled { + for (name, (location, is_pub)) in &self.defined_functions { + if name == "main" || *is_pub { + continue; // main and pub functions are considered used + } + if self.function_uses.get(name).copied().unwrap_or(0) == 0 { + self.diagnostics.push( + LintDiagnostic::new( + "unused_function", + format!("unused function `{}`", name), + self.config.unused_function.severity, + ) + .with_location(location.clone()) + .with_suggestion("remove or add `pub` if intended for export"), + ); + // Note: No auto-fix for unused functions - removing code is always unsafe + } + } + } + } + + fn check_assertion_density(&mut self) { + if !self.config.low_assertion_density.enabled || self.function_count == 0 { + return; + } + + let density = self.total_assertions as f64 / self.function_count as f64; + + if density < self.config.min_assertions_per_function { + self.diagnostics.push(LintDiagnostic::new( + "low_assertion_density", + format!( + "assertion density is {:.1} per function, below minimum of {:.1} (NASA PoT Rule 5)", + density, self.config.min_assertions_per_function + ), + self.config.low_assertion_density.severity, + ) + .with_suggestion("add `assert()` calls to verify invariants and preconditions")); + } + } + + fn check_angle_precision(&mut self, angle: &ast::AngleLit) { + use crate::ast::AngleUnit; + use crate::rational::Rational; + + // Check if using radians when exact turn fractions exist + if self.config.prefer_turns_over_radians.enabled + && let AngleUnit::Rad = angle.unit + { + // Try to evaluate the expression to see if it's a common angle + if let Some(suggestion) = self.suggest_turn_equivalent(angle) { + self.diagnostics.push( + LintDiagnostic::new( + "prefer_turns_over_radians", + "radians can lose precision; consider using turns for exact representation" + .to_string(), + self.config.prefer_turns_over_radians.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(&suggestion), + ); + } + } + + // Check if using decimal turns when fractions exist + if self.config.prefer_fraction_turns.enabled + && let AngleUnit::Turns = angle.unit + && let Expr::FloatLit(lit) = &angle.value + && let Some(fraction_str) = suggest_fraction_for_decimal(lit.value) + { + let mut diag = LintDiagnostic::new( + "prefer_fraction_turns", + format!( + "decimal `{}` can be expressed exactly as `{}`", + lit.value, fraction_str + ), + self.config.prefer_fraction_turns.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(format!( + "use `{} turns` for exact representation", + fraction_str + )); + + // Generate safe fix: replace the float literal with the fraction + if let (Some(source), Some(lit_loc)) = (&self.source, &lit.location) { + let start = location_to_offset(source, lit_loc.line, lit_loc.column); + let end = location_to_offset(source, lit_loc.end_line, lit_loc.end_column); + diag = diag.with_fix(LintFix { + start, + end, + replacement: fraction_str.clone(), + safety: FixSafety::Safe, + }); + } + + self.diagnostics.push(diag); + } + + // Check if using float literals that could be exact fractions or std constants + if self.config.prefer_exact_angles.enabled + && let Expr::FloatLit(lit) = &angle.value + { + let value = lit.value; + + // Check if this float could be a rational fraction + if let Some(r) = Rational::from_f64_common(value) { + // Only suggest if it's a "nice" fraction (small denominator) + if r.denominator() <= 16 && !r.is_integer() { + let suggestion = if r.numerator() == 1 { + format!("1/{}", r.denominator()) + } else { + format!("{}/{}", r.numerator(), r.denominator()) + }; + + self.diagnostics.push( + LintDiagnostic::new( + "prefer_exact_angles", + format!( + "float literal `{}` can be expressed exactly as fraction `{}`", + value, suggestion + ), + self.config.prefer_exact_angles.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(format!( + "use `{} {}` for exact representation", + suggestion, + match angle.unit { + AngleUnit::Turns => "turns", + AngleUnit::Rad => "rad", + } + )), + ); + } + } + + // For radians, also check if it's a pi multiple (like 3.14159...) + if let AngleUnit::Rad = angle.unit + && let Some((n, d)) = Rational::from_f64_pi_multiple(value) + { + let pi_suggestion = if n == 1 && d == 1 { + "std.f64.pi".to_string() + } else if n == 1 { + format!("std.f64.pi/{}", d) + } else if d == 1 { + format!("{}*std.f64.pi", n) + } else { + format!("{}*std.f64.pi/{}", n, d) + }; + + // Also suggest the turns equivalent + let turns_fraction = Rational::new(n, 2 * d as i64); + let turns_suggestion = if turns_fraction.numerator() == 1 { + format!("1/{}", turns_fraction.denominator()) + } else { + format!( + "{}/{}", + turns_fraction.numerator(), + turns_fraction.denominator() + ) + }; + + self.diagnostics.push( + LintDiagnostic::new( + "prefer_exact_angles", + format!( + "float `{}` appears to be {}*pi/{}; use exact form or turns", + value, n, d + ), + self.config.prefer_exact_angles.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(format!( + "use `{} rad` or `{} turns` for exact representation", + pi_suggestion, turns_suggestion + )), + ); + } + } + } + + fn suggest_turn_equivalent(&self, angle: &ast::AngleLit) -> Option { + use crate::comptime::ComptimeEvaluator; + use crate::rational::Rational; + + let mut eval = ComptimeEvaluator::new(); + let value = eval.eval_expr(&angle.value).ok()?; + let radians = match value { + crate::comptime::ComptimeValue::Float(f) => f, + crate::comptime::ComptimeValue::Int(i) => i as f64, + crate::comptime::ComptimeValue::Rational(r) => r.to_f64(), + _ => return None, + }; + + // Use Rational to detect pi multiples and convert to turns + if let Some(turns_rational) = Rational::radians_to_turns(radians) + && (!turns_rational.is_integer() || turns_rational.numerator() != 0) + { + let suggestion = if turns_rational.numerator() == 1 { + format!("1/{}", turns_rational.denominator()) + } else { + format!( + "{}/{}", + turns_rational.numerator(), + turns_rational.denominator() + ) + }; + return Some(format!("use `{} turns` instead", suggestion)); + } + + None + } +} + +/// Suggest a fraction representation for a decimal turn value +fn suggest_fraction_for_decimal(value: f64) -> Option { + const TOLERANCE: f64 = 1e-10; + let common_fractions = [ + (0.5, "1/2"), + (0.25, "1/4"), + (0.125, "1/8"), + (0.0625, "1/16"), + (1.0 / 3.0, "1/3"), + (1.0 / 6.0, "1/6"), + (0.75, "3/4"), + (0.375, "3/8"), + (2.0 / 3.0, "2/3"), + (0.1, "1/10"), + (0.2, "1/5"), + ]; + + for (frac_val, frac_str) in common_fractions { + if (value - frac_val).abs() < TOLERANCE { + return Some(frac_str.to_string()); + } + } + + None +} + +// ============================================================================= +// Naming Convention Helpers +// ============================================================================= + +fn is_snake_case(s: &str) -> bool { + if s.is_empty() { + return false; + } + + let mut chars = s.chars().peekable(); + + // First char must be lowercase or underscore + match chars.next() { + Some(c) if c.is_ascii_lowercase() || c == '_' => {} + _ => return false, + } + + // Rest must be lowercase, digits, or underscores + for c in chars { + if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' { + return false; + } + } + + // No double underscores + !s.contains("__") +} + +fn is_pascal_case(s: &str) -> bool { + if s.is_empty() { + return false; + } + + let mut chars = s.chars(); + + // First char must be uppercase + match chars.next() { + Some(c) if c.is_ascii_uppercase() => {} + _ => return false, + } + + // No underscores allowed and must have at least one lowercase letter + // (to distinguish from SCREAMING_SNAKE_CASE) + !s.contains('_') && s.chars().any(|c| c.is_ascii_lowercase()) +} + +fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + + for (i, c) in s.chars().enumerate() { + if c.is_ascii_uppercase() { + if i > 0 { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + } else { + result.push(c); + } + } + + result +} + +fn to_pascal_case(s: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + for c in s.chars() { + if c == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(c.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(c); + } + } + + result +} + +/// Count approximate lines in a block (for function length checking). +fn count_block_lines(block: &ast::Block) -> usize { + if let (Some(start), Some(end)) = (&block.location, block.statements.last()) + && let Some(end_loc) = get_stmt_location(end) + { + return (end_loc.line.saturating_sub(start.line) + 1) as usize; + } + + // Fallback: count statements + block.statements.len() +} + +fn get_stmt_location(stmt: &Stmt) -> Option { + match stmt { + Stmt::Binding(b) => b.location.clone(), + Stmt::Expr(e) => e.location.clone(), + Stmt::If(i) => i.location.clone(), + Stmt::For(f) => f.location.clone(), + Stmt::Return(r) => r.location.clone(), + Stmt::Block(b) => b.location.clone(), + Stmt::Tick(t) => t.location.clone(), + Stmt::Assign(a) => a.location.clone(), + Stmt::Gate(g) => g.location.clone(), + Stmt::Measure(m) => m.location.clone(), + _ => None, + } +} + +// ============================================================================= +// Public API +// ============================================================================= + +/// Lint a program with default configuration. +pub fn lint(program: &Program) -> Vec { + Linter::new(LintConfig::default()).lint(program) +} + +/// Lint a program with strict configuration. +pub fn lint_strict(program: &Program) -> Vec { + Linter::new(LintConfig::strict()).lint(program) +} + +// ============================================================================= +// Fix Application +// ============================================================================= + +/// Result of applying fixes. +#[derive(Debug)] +pub struct FixResult { + /// The modified source code. + pub source: String, + /// Number of safe fixes applied. + pub safe_fixes_applied: usize, + /// Number of unsafe fixes applied. + pub unsafe_fixes_applied: usize, + /// Number of fixes skipped (due to safety or conflicts). + pub fixes_skipped: usize, +} + +/// Apply fixes from diagnostics to source code. +/// +/// # Arguments +/// * `source` - Original source code +/// * `diagnostics` - Lint diagnostics with potential fixes +/// * `include_unsafe` - Whether to apply unsafe fixes +/// +/// # Returns +/// The modified source code and statistics about fixes applied. +pub fn apply_fixes( + source: &str, + diagnostics: &[LintDiagnostic], + include_unsafe: bool, +) -> FixResult { + // Collect all applicable fixes + let mut fixes: Vec<&LintFix> = diagnostics + .iter() + .filter_map(|d| d.fix.as_ref()) + .filter(|f| include_unsafe || f.safety == FixSafety::Safe) + .collect(); + + // Sort by start position (descending) so we can apply from end to start + // This prevents offset shifts from affecting earlier fixes + fixes.sort_by_key(|f| std::cmp::Reverse(f.start)); + + // Check for overlapping fixes and remove conflicts + let mut result = source.to_string(); + let mut safe_applied = 0; + let mut unsafe_applied = 0; + let mut skipped = 0; + let mut last_start = usize::MAX; + + for fix in fixes { + // Skip if this fix overlaps with a previously applied fix + if fix.end > last_start { + skipped += 1; + continue; + } + + // Apply the fix + if fix.start <= result.len() && fix.end <= result.len() { + result.replace_range(fix.start..fix.end, &fix.replacement); + last_start = fix.start; + + match fix.safety { + FixSafety::Safe => safe_applied += 1, + FixSafety::Unsafe => unsafe_applied += 1, + } + } else { + skipped += 1; + } + } + + // Count skipped fixes (those not matching safety criteria) + let total_fixes = diagnostics.iter().filter(|d| d.fix.is_some()).count(); + let not_applied = total_fixes - safe_applied - unsafe_applied; + + FixResult { + source: result, + safe_fixes_applied: safe_applied, + unsafe_fixes_applied: unsafe_applied, + fixes_skipped: skipped + not_applied, + } +} + +/// Compute byte offset from line/column (1-indexed). +pub fn location_to_offset(source: &str, line: u32, column: u32) -> usize { + source + .lines() + .take(line.saturating_sub(1) as usize) + .map(|l| l.len() + 1) // +1 for newline + .sum::() + + column.saturating_sub(1) as usize +} + +/// Compute byte offset for end of a source location. +pub fn location_end_offset(source: &str, loc: &ast::SourceLocation) -> usize { + location_to_offset(source, loc.end_line, loc.end_column) +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse; + + fn lint_source(source: &str) -> Vec { + let program = parse(source).expect("parse failed"); + Linter::new(LintConfig::default()).lint(&program) + } + + fn lint_source_strict(source: &str) -> Vec { + let program = parse(source).expect("parse failed"); + Linter::new(LintConfig::strict()).lint(&program) + } + + #[test] + fn test_snake_case_detection() { + assert!(is_snake_case("foo")); + assert!(is_snake_case("foo_bar")); + assert!(is_snake_case("foo_bar_baz")); + assert!(is_snake_case("_foo")); + assert!(is_snake_case("foo2")); + + assert!(!is_snake_case("Foo")); + assert!(!is_snake_case("fooBar")); + assert!(!is_snake_case("FOO")); + assert!(!is_snake_case("foo__bar")); + } + + #[test] + fn test_pascal_case_detection() { + assert!(is_pascal_case("Foo")); + assert!(is_pascal_case("FooBar")); + assert!(is_pascal_case("FooBarBaz")); + + assert!(!is_pascal_case("foo")); + assert!(!is_pascal_case("foo_bar")); + assert!(!is_pascal_case("FOO")); + } + + #[test] + fn test_function_naming_lint() { + let diags = lint_source( + r#" + fn badName() -> unit { + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "function_naming"), + "expected function_naming lint" + ); + } + + #[test] + fn test_good_function_naming() { + let diags = lint_source( + r#" + fn good_name() -> unit { + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "function_naming"), + "unexpected function_naming lint" + ); + } + + #[test] + fn test_type_naming_lint() { + // Note: The struct syntax `Name := struct {}` parses as a Binding in the current grammar. + // Type naming is checked on TopLevelDecl::Struct, so this test would need + // the grammar to be updated. For now, just verify the is_pascal_case function works. + assert!(!is_pascal_case("bad_name")); + assert!(is_pascal_case("GoodName")); + } + + #[test] + fn test_unused_variable_lint() { + let diags = lint_source( + r#" + fn main() -> unit { + x := 5; + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "unused_variable"), + "expected unused_variable lint" + ); + } + + #[test] + fn test_underscore_unused_ok() { + let diags = lint_source( + r#" + fn main() -> unit { + _x := 5; + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_variable"), + "underscore prefix should suppress unused warning" + ); + } + + #[test] + fn test_deep_nesting_lint() { + let diags = lint_source_strict( + r#" + fn main() -> unit { + if (true) { + if (true) { + if (true) { + if (true) { + if (true) { + x := 1; + } + } + } + } + } + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "deep_nesting"), + "expected deep_nesting lint" + ); + } + + #[test] + fn test_severity_display() { + let diag = LintDiagnostic::new("test_rule", "test message", Severity::Warning); + let s = diag.to_string(); + assert!(s.contains("warning")); + assert!(s.contains("test message")); + assert!(s.contains("test_rule")); + } + + // ========================================================================= + // Assertion Density Tests + // ========================================================================= + + #[test] + fn test_low_assertion_density() { + let diags = lint_source_strict( + r#" + fn foo() -> unit { + x := 1; + return unit; + } + fn bar() -> unit { + y := 2; + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "low_assertion_density"), + "expected low_assertion_density lint when no assertions present" + ); + } + + #[test] + fn test_good_assertion_density() { + let diags = lint_source_strict( + r#" + fn foo() -> unit { + x := 1; + assert(x > 0); + assert(x < 10); + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "low_assertion_density"), + "should not warn when assertion density is good" + ); + } + + // ========================================================================= + // Unused Function Tests + // ========================================================================= + + #[test] + fn test_unused_function_lint() { + let diags = lint_source_strict( + r#" + fn helper() -> unit { + return unit; + } + pub fn main() -> unit { + return unit; + } + "#, + ); + + assert!( + diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("helper")), + "expected unused_function lint for helper" + ); + } + + #[test] + fn test_used_function_no_lint() { + let diags = lint_source_strict( + r#" + fn helper() -> unit { + return unit; + } + pub fn main() -> unit { + helper(); + return unit; + } + "#, + ); + + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("helper")), + "should not lint used function" + ); + } + + #[test] + fn test_pub_function_not_unused() { + let diags = lint_source_strict( + r#" + pub fn exported() -> unit { + return unit; + } + "#, + ); + + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("exported")), + "pub functions should not be considered unused" + ); + } + + #[test] + fn test_main_not_unused() { + let diags = lint_source_strict( + r#" + fn main() -> unit { + return unit; + } + "#, + ); + + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("main")), + "main should never be considered unused" + ); + } + + // ========================================================================= + // Variable Usage Tests + // ========================================================================= + + #[test] + fn test_used_variable_no_lint() { + let diags = lint_source( + r#" + fn main() -> unit { + x := 5; + y := x + 1; + return unit; + } + "#, + ); + + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`x`")), + "used variable should not be flagged" + ); + } + + #[test] + fn test_multiple_unused_variables() { + let diags = lint_source( + r#" + fn main() -> unit { + a := 1; + b := 2; + c := 3; + return unit; + } + "#, + ); + + let unused_count = diags.iter().filter(|d| d.rule == "unused_variable").count(); + assert!( + unused_count >= 3, + "expected at least 3 unused variable lints, got {}", + unused_count + ); + } + + // ========================================================================= + // Naming Convention Tests + // ========================================================================= + + #[test] + fn test_camel_case_function_lint() { + let diags = lint_source( + r#" + fn myFunction() -> unit { + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "function_naming"), + "camelCase function should trigger lint" + ); + } + + #[test] + fn test_screaming_snake_case_function_lint() { + let diags = lint_source( + r#" + fn MY_FUNCTION() -> unit { + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "function_naming"), + "SCREAMING_SNAKE_CASE function should trigger lint" + ); + } + + #[test] + fn test_single_letter_variable_ok() { + let diags = lint_source( + r#" + fn main() -> unit { + x := 1; + y := 2; + q := x + y; + return unit; + } + "#, + ); + + // Single letter names (x, y, q, etc.) should not trigger naming lint + assert!( + !diags.iter().any(|d| d.rule == "variable_naming"), + "single letter variables should be allowed" + ); + } + + // ========================================================================= + // Nesting Depth Tests + // ========================================================================= + + #[test] + fn test_acceptable_nesting() { + let diags = lint_source_strict( + r#" + fn main() -> unit { + if (true) { + if (true) { + x := 1; + } + } + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "deep_nesting"), + "nesting depth of 2-3 should be acceptable" + ); + } + + #[test] + fn test_for_loop_nesting_counts() { + let diags = lint_source_strict( + r#" + fn main() -> unit { + for i in 0..10 { + for j in 0..10 { + for k in 0..10 { + for l in 0..10 { + for m in 0..10 { + x := 1; + } + } + } + } + } + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "deep_nesting"), + "deeply nested for loops should trigger lint" + ); + } + + // ========================================================================= + // Configuration Tests + // ========================================================================= + + #[test] + fn test_minimal_config_fewer_warnings() { + let program = parse( + r#" + fn badName() -> unit { + x := 5; + return unit; + } + "#, + ) + .expect("parse failed"); + + let strict_diags = Linter::new(LintConfig::strict()).lint(&program); + let minimal_diags = Linter::new(LintConfig::minimal()).lint(&program); + + assert!( + minimal_diags.len() < strict_diags.len(), + "minimal config should produce fewer diagnostics" + ); + } + + #[test] + fn test_relaxed_config_warnings_not_errors() { + let program = parse( + r#" + fn badName() -> unit { + return unit; + } + "#, + ) + .expect("parse failed"); + + let relaxed_diags = Linter::new(LintConfig::relaxed()).lint(&program); + + // In relaxed mode, function naming should be a warning, not error + let naming_diag = relaxed_diags.iter().find(|d| d.rule == "function_naming"); + if let Some(diag) = naming_diag { + assert!( + matches!(diag.severity, Severity::Warning | Severity::Hint), + "relaxed config should use warnings, not errors" + ); + } + } + + // ========================================================================= + // Suggestion Tests + // ========================================================================= + + #[test] + fn test_function_naming_suggestion() { + let diags = lint_source( + r#" + fn badName() -> unit { + return unit; + } + "#, + ); + + let naming_diag = diags.iter().find(|d| d.rule == "function_naming"); + assert!(naming_diag.is_some(), "should have function_naming lint"); + + let suggestion = &naming_diag.unwrap().suggestion; + assert!(suggestion.is_some(), "should have a suggestion"); + assert!( + suggestion.as_ref().unwrap().contains("bad_name"), + "suggestion should include snake_case version" + ); + } + + #[test] + fn test_unused_variable_suggestion() { + let diags = lint_source( + r#" + fn main() -> unit { + myVar := 5; + return unit; + } + "#, + ); + + let unused_diag = diags.iter().find(|d| d.rule == "unused_variable"); + assert!(unused_diag.is_some(), "should have unused_variable lint"); + + let suggestion = &unused_diag.unwrap().suggestion; + assert!(suggestion.is_some(), "should have a suggestion"); + assert!( + suggestion.as_ref().unwrap().contains("_myVar"), + "suggestion should recommend underscore prefix" + ); + } + + // ========================================================================= + // Quantum-Specific Tests + // ========================================================================= + + #[test] + fn test_gate_operations_track_usage() { + let diags = lint_source( + r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + return unit; + } + "#, + ); + + // q should be considered used because of gate operations + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`q`")), + "allocator used in gates should not be flagged as unused" + ); + } + + // ========================================================================= + // Edge Cases + // ========================================================================= + + #[test] + fn test_empty_program() { + let diags = lint_source(""); + // Should not crash, might have assertion density warning + assert!(diags.iter().all(|d| d.rule != "unused_variable")); + } + + #[test] + fn test_program_with_only_comments() { + // Comments are stripped during parsing, so this is effectively empty + let result = parse("// just a comment"); + // Parser may or may not accept this - either way, linter shouldn't crash + if let Ok(program) = result { + let _diags = Linter::new(LintConfig::default()).lint(&program); + } + } + + #[test] + fn test_to_snake_case_conversion() { + assert_eq!(to_snake_case("myFunction"), "my_function"); + assert_eq!(to_snake_case("MyClass"), "my_class"); + assert_eq!(to_snake_case("XMLParser"), "x_m_l_parser"); + assert_eq!(to_snake_case("already_snake"), "already_snake"); + } + + #[test] + fn test_to_pascal_case_conversion() { + assert_eq!(to_pascal_case("my_type"), "MyType"); + assert_eq!(to_pascal_case("some_struct"), "SomeStruct"); + assert_eq!(to_pascal_case("already"), "Already"); + } + + // ========================================================================= + // Critical Edge Cases + // ========================================================================= + + #[test] + fn test_variable_used_in_return() { + let diags = lint_source( + r#" + fn add(a: u32, b: u32) -> u32 { + result := a + b; + return result; + } + "#, + ); + + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("result")), + "variable used in return should not be flagged" + ); + } + + #[test] + fn test_variable_used_in_condition() { + let diags = lint_source( + r#" + fn main() -> unit { + flag := true; + if (flag) { + x := 1; + } + return unit; + } + "#, + ); + + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("flag")), + "variable used in condition should not be flagged" + ); + } + + #[test] + fn test_parameter_unused() { + let diags = lint_source( + r#" + fn ignore_param(x: u32) -> unit { + return unit; + } + "#, + ); + + assert!( + diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("x")), + "unused parameter should be flagged" + ); + } + + #[test] + fn test_parameter_underscore_prefix_ok() { + let diags = lint_source( + r#" + fn ignore_param(_x: u32) -> unit { + return unit; + } + "#, + ); + + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("_x")), + "underscore-prefixed parameter should not be flagged" + ); + } + + #[test] + fn test_shadowed_variable() { + let diags = lint_source( + r#" + fn main() -> unit { + x := 1; + x := 2; + y := x; + return unit; + } + "#, + ); + + // The first x is shadowed but that's valid - just check we don't crash + assert!(diags.iter().filter(|d| d.rule == "unused_variable").count() <= 2); + } + + #[test] + fn test_recursive_function_call() { + let diags = lint_source( + r#" + fn factorial(n: u32) -> u32 { + if (n <= 1) { + return 1; + } + return n * factorial(n - 1); + } + "#, + ); + + // factorial calls itself, so it should be considered used + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("factorial")), + "recursive function should not be flagged as unused" + ); + } + + #[test] + fn test_mutual_recursion() { + let diags = lint_source( + r#" + fn is_even(n: u32) -> bool { + if (n == 0) { return true; } + return is_odd(n - 1); + } + fn is_odd(n: u32) -> bool { + if (n == 0) { return false; } + return is_even(n - 1); + } + pub fn main() -> unit { + r := is_even(10); + return unit; + } + "#, + ); + + // Both functions are used via mutual recursion + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("is_even")), + "mutually recursive function should not be flagged" + ); + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("is_odd")), + "mutually recursive function should not be flagged" + ); + } + + #[test] + fn test_nested_blocks_variable_scope() { + let diags = lint_source( + r#" + fn main() -> unit { + { + inner := 5; + } + return unit; + } + "#, + ); + + assert!( + diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("inner")), + "variable unused in nested block should be flagged" + ); + } + + #[test] + fn test_tick_block_operations() { + let diags = lint_source( + r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + tick { + h q[0]; + h q[1]; + } + return unit; + } + "#, + ); + + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("q")), + "allocator used in tick block should not be flagged" + ); + } + + #[test] + fn test_measurement_result_unused() { + // This would test unused_measurement rule if implemented + let diags = lint_source( + r#" + pub fn main() -> unit { + mut q := qalloc(1); + pz q; + h q[0]; + mz(u1) q[0]; + return unit; + } + "#, + ); + + // Currently we don't track measurement results specially + // Just verify it doesn't crash + let _ = diags; + } + + #[test] + fn test_complex_expression_usage() { + let diags = lint_source( + r#" + fn main() -> unit { + a := 1; + b := 2; + c := 3; + result := (a + b) * c; + return unit; + } + "#, + ); + + // a, b, c are all used in the expression + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`a`")), + "variable used in expression should not be flagged" + ); + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`b`")), + "variable used in expression should not be flagged" + ); + assert!( + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`c`")), + "variable used in expression should not be flagged" + ); + } + + #[test] + fn test_all_severities() { + // Test that all severity levels display correctly + for (sev, expected) in [ + (Severity::Hint, "hint"), + (Severity::Warning, "warning"), + (Severity::Error, "error"), + (Severity::Deny, "deny"), + ] { + let diag = LintDiagnostic::new("test", "msg", sev); + assert!(diag.to_string().contains(expected)); + } + } + + #[test] + fn test_diagnostic_with_location() { + let loc = crate::ast::SourceLocation { + line: 10, + column: 5, + end_line: 10, + end_column: 15, + file: Some("test.zlp".to_string()), + }; + let diag = LintDiagnostic::new("test", "msg", Severity::Error).with_location(loc); + let s = diag.to_string(); + assert!(s.contains("10")); + assert!(s.contains("5")); + } + + #[test] + fn test_diagnostic_with_suggestion() { + let diag = LintDiagnostic::new("test", "msg", Severity::Warning) + .with_suggestion("try this instead"); + let s = diag.to_string(); + assert!(s.contains("try this instead")); + } + + #[test] + fn test_prefer_fraction_turns() { + // Test that decimal turns trigger a suggestion for fractions + let source = r#" + fn main() -> unit { + mut q := qalloc(1); + rz(0.25 turns) q[0]; + return unit; + } + "#; + let program = crate::parse(source).unwrap(); + let linter = Linter::new(LintConfig::strict()); + let diagnostics = linter.lint(&program); + + // Should have a suggestion to use 1/4 instead of 0.25 + let fraction_diags: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "prefer_fraction_turns") + .collect(); + assert!( + !fraction_diags.is_empty(), + "Expected prefer_fraction_turns diagnostic for 0.25 turns" + ); + } + + #[test] + fn test_fraction_turns_no_warning() { + // Test that fraction turns don't trigger warnings + let source = r#" + fn main() -> unit { + mut q := qalloc(1); + rz(1/4 turns) q[0]; + return unit; + } + "#; + let program = crate::parse(source).unwrap(); + let linter = Linter::new(LintConfig::strict()); + let diagnostics = linter.lint(&program); + + // Should NOT have prefer_fraction_turns diagnostic + let fraction_diags: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "prefer_fraction_turns") + .collect(); + assert!( + fraction_diags.is_empty(), + "Should not warn about fraction turns: {:?}", + fraction_diags + ); + } + + // ========================================================================= + // Auto-fix Tests + // ========================================================================= + + #[test] + fn test_fix_unused_variable() { + let source = r#"fn main() -> unit { + x := 5; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + // Should have an unused_variable diagnostic with a fix + let unused_diag = diagnostics.iter().find(|d| d.rule == "unused_variable"); + assert!(unused_diag.is_some(), "Expected unused_variable diagnostic"); + + let diag = unused_diag.unwrap(); + assert!(diag.fix.is_some(), "Expected fix to be available"); + + let fix = diag.fix.as_ref().unwrap(); + assert_eq!(fix.safety, FixSafety::Safe); + assert_eq!(fix.replacement, "_x"); + } + + #[test] + fn test_apply_unused_variable_fix() { + let source = r#"fn main() -> unit { + x := 5; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + let result = apply_fixes(source, &diagnostics, false); + + assert_eq!(result.safe_fixes_applied, 1); + assert!( + result.source.contains("_x := 5"), + "Expected fix to prefix with underscore" + ); + } + + #[test] + fn test_apply_multiple_fixes() { + let source = r#"fn main() -> unit { + a := 1; + b := 2; + c := 3; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + let result = apply_fixes(source, &diagnostics, false); + + // Should fix all unused variables + assert!( + result.safe_fixes_applied >= 3, + "Expected at least 3 fixes, got {}", + result.safe_fixes_applied + ); + assert!(result.source.contains("_a := 1")); + assert!(result.source.contains("_b := 2")); + assert!(result.source.contains("_c := 3")); + } + + #[test] + fn test_fix_decimal_turns() { + let source = r#"fn main() -> unit { + mut q := qalloc(1); + rz(0.25 turns) q[0]; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + // Should have a prefer_fraction_turns diagnostic with a fix + let fraction_diag = diagnostics + .iter() + .find(|d| d.rule == "prefer_fraction_turns"); + assert!( + fraction_diag.is_some(), + "Expected prefer_fraction_turns diagnostic" + ); + + let diag = fraction_diag.unwrap(); + assert!(diag.fix.is_some(), "Expected fix to be available"); + + let fix = diag.fix.as_ref().unwrap(); + assert_eq!(fix.safety, FixSafety::Safe); + assert_eq!(fix.replacement, "1/4"); + } + + #[test] + fn test_apply_decimal_turns_fix() { + let source = r#"fn main() -> unit { + mut q := qalloc(1); + rz(0.25 turns) q[0]; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + let result = apply_fixes(source, &diagnostics, false); + + assert!(result.safe_fixes_applied >= 1); + assert!( + result.source.contains("1/4 turns"), + "Expected 0.25 to be replaced with 1/4" + ); + } + + #[test] + fn test_has_safe_fix() { + let diag_with_safe_fix = + LintDiagnostic::new("test", "msg", Severity::Warning).with_fix(LintFix { + start: 0, + end: 1, + replacement: "x".to_string(), + safety: FixSafety::Safe, + }); + assert!(diag_with_safe_fix.has_safe_fix()); + assert!(diag_with_safe_fix.has_fix()); + + let diag_with_unsafe_fix = + LintDiagnostic::new("test", "msg", Severity::Warning).with_fix(LintFix { + start: 0, + end: 1, + replacement: "x".to_string(), + safety: FixSafety::Unsafe, + }); + assert!(!diag_with_unsafe_fix.has_safe_fix()); + assert!(diag_with_unsafe_fix.has_fix()); + + let diag_no_fix = LintDiagnostic::new("test", "msg", Severity::Warning); + assert!(!diag_no_fix.has_safe_fix()); + assert!(!diag_no_fix.has_fix()); + } + + #[test] + fn test_no_fixes_to_apply() { + let source = r#"fn main() -> unit { + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + let result = apply_fixes(source, &diagnostics, false); + + assert_eq!(result.safe_fixes_applied, 0); + assert_eq!(result.unsafe_fixes_applied, 0); + } +} diff --git a/exp/zlup/src/logging.rs b/exp/zlup/src/logging.rs new file mode 100644 index 000000000..d60037f78 --- /dev/null +++ b/exp/zlup/src/logging.rs @@ -0,0 +1,111 @@ +//! Logging infrastructure for the Zlup compiler. +//! +//! Uses the standard `RUST_LOG` environment variable to control compiler log output. +//! This follows Rust conventions and integrates well with other Rust tooling. +//! +//! Note: For logging within Zluppy *programs* (the language feature), see the +//! `log` builtin which is controlled by `ZLUP_LOG` at runtime. +//! +//! # Usage +//! +//! Set the `RUST_LOG` environment variable to control compiler logging: +//! +//! ```bash +//! # Show all debug messages +//! RUST_LOG=debug zlup build +//! +//! # Show only warnings and errors +//! RUST_LOG=warn zlup build +//! +//! # Target specific modules +//! RUST_LOG=zlup::parser=trace zlup build +//! RUST_LOG=zlup::semantic=debug,zlup::codegen=info zlup build +//! +//! # Trace everything +//! RUST_LOG=trace zlup build +//! ``` +//! +//! # Log Levels +//! +//! - `error` - Unrecoverable errors +//! - `warn` - Recoverable issues or deprecation warnings +//! - `info` - High-level progress information +//! - `debug` - Detailed debugging information +//! - `trace` - Very verbose tracing (e.g., AST dumps) + +use std::io::Write; + +/// Initialize the Zlup compiler logger. +/// +/// Reads the standard `RUST_LOG` environment variable to configure log levels. +/// If `RUST_LOG` is not set, logging is disabled (no output). +/// +/// This should be called once at the start of the program, typically +/// in `main()`. +/// +/// # Example +/// +/// ```ignore +/// fn main() { +/// zlup::logging::init(); +/// // ... rest of program +/// } +/// ``` +pub fn init() { + env_logger::Builder::from_env(env_logger::Env::default()) + .format(|buf, record| { + let level_style = buf.default_level_style(record.level()); + writeln!( + buf, + "{level_style}[{level}]{level_style:#} {target}: {args}", + level = record.level(), + target = record.target(), + args = record.args(), + ) + }) + .init(); +} + +/// Initialize the Zlup compiler logger with a default level. +/// +/// If `RUST_LOG` is not set, uses the provided default level. +/// This is useful for development builds where you want some +/// output by default. +/// +/// # Example +/// +/// ```ignore +/// fn main() { +/// // Default to info level if RUST_LOG not set +/// zlup::logging::init_with_default("info"); +/// } +/// ``` +pub fn init_with_default(default_level: &str) { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) + .format(|buf, record| { + let level_style = buf.default_level_style(record.level()); + writeln!( + buf, + "{level_style}[{level}]{level_style:#} {target}: {args}", + level = record.level(), + target = record.target(), + args = record.args(), + ) + }) + .init(); +} + +/// Check if logging is enabled at the given level. +/// +/// Useful for avoiding expensive formatting when logging is disabled. +/// +/// # Example +/// +/// ```ignore +/// if zlup::logging::enabled(log::Level::Debug) { +/// log::debug!("Expensive debug info: {:?}", compute_debug_info()); +/// } +/// ``` +pub fn enabled(level: log::Level) -> bool { + log::log_enabled!(level) +} diff --git a/exp/zlup/src/lsp/main.rs b/exp/zlup/src/lsp/main.rs new file mode 100644 index 000000000..bccca0645 --- /dev/null +++ b/exp/zlup/src/lsp/main.rs @@ -0,0 +1,888 @@ +//! Zlups - Language Server Protocol implementation for Zlup +//! +//! Provides IDE features like diagnostics, syntax highlighting, hover, and completions. + +use std::collections::BTreeMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer, LspService, Server}; + +use zlup::ast::SourceLocation; +use zlup::semantic::SemanticAnalyzer; + +mod semantic_tokens; + +use semantic_tokens::{LEGEND, token_types_for_source}; + +/// Document state tracked by the server +#[derive(Debug, Clone)] +struct Document { + content: String, + /// Document version for incremental updates (reserved for future use) + #[allow(dead_code)] + version: i32, +} + +/// The Zlups language server +struct ZlupsServer { + client: Client, + documents: Arc>>, +} + +impl ZlupsServer { + fn new(client: Client) -> Self { + Self { + client, + documents: Arc::new(RwLock::new(BTreeMap::new())), + } + } + + /// Analyze a document and publish diagnostics + async fn analyze_and_publish(&self, uri: Url, content: &str, version: i32) { + let diagnostics = self.get_diagnostics(content); + self.client + .publish_diagnostics(uri, diagnostics, Some(version)) + .await; + } + + /// Get diagnostics for the given source code + fn get_diagnostics(&self, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + // Try parsing + let program = match zlup::parse(source) { + Ok(p) => p, + Err(e) => { + // Extract location from pest error + let error_str = e.to_string(); + let (line, col) = extract_pest_location(&error_str); + let message = friendly_parse_error(&error_str); + + diagnostics.push(Diagnostic { + range: Range { + start: Position { + line: line.saturating_sub(1), + character: col.saturating_sub(1), + }, + end: Position { + line: line.saturating_sub(1), + character: col, + }, + }, + severity: Some(DiagnosticSeverity::ERROR), + code: Some(NumberOrString::String("parse-error".to_string())), + source: Some("zlups".to_string()), + message, + ..Default::default() + }); + return diagnostics; + } + }; + + // Try semantic analysis + let mut analyzer = SemanticAnalyzer::new(); + if let Err(e) = analyzer.analyze(&program) { + let (range, message) = if let Some(loc) = e.location() { + (location_to_range(loc), e.to_string()) + } else { + ( + Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: 0, + character: 1, + }, + }, + e.to_string(), + ) + }; + + diagnostics.push(Diagnostic { + range, + severity: Some(DiagnosticSeverity::ERROR), + code: Some(NumberOrString::String("semantic-error".to_string())), + source: Some("zlups".to_string()), + message, + ..Default::default() + }); + } + + diagnostics + } +} + +#[tower_lsp::async_trait] +impl LanguageServer for ZlupsServer { + async fn initialize(&self, _: InitializeParams) -> Result { + Ok(InitializeResult { + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + semantic_tokens_provider: Some( + SemanticTokensServerCapabilities::SemanticTokensOptions( + SemanticTokensOptions { + legend: LEGEND.clone(), + full: Some(SemanticTokensFullOptions::Bool(true)), + range: Some(false), + ..Default::default() + }, + ), + ), + hover_provider: Some(HoverProviderCapability::Simple(true)), + completion_provider: Some(CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..Default::default() + }), + definition_provider: Some(OneOf::Left(true)), + document_formatting_provider: Some(OneOf::Left(true)), + ..Default::default() + }, + server_info: Some(ServerInfo { + name: "zlups".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }) + } + + async fn initialized(&self, _: InitializedParams) { + self.client + .log_message(MessageType::INFO, "Zlups server initialized") + .await; + } + + async fn shutdown(&self) -> Result<()> { + Ok(()) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let uri = params.text_document.uri; + let content = params.text_document.text; + let version = params.text_document.version; + + { + let mut docs = self.documents.write().await; + docs.insert( + uri.clone(), + Document { + content: content.clone(), + version, + }, + ); + } + + self.analyze_and_publish(uri, &content, version).await; + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + let uri = params.text_document.uri; + let version = params.text_document.version; + + // We use full sync, so there's exactly one change with the full content + if let Some(change) = params.content_changes.into_iter().next() { + let content = change.text; + + { + let mut docs = self.documents.write().await; + docs.insert( + uri.clone(), + Document { + content: content.clone(), + version, + }, + ); + } + + self.analyze_and_publish(uri, &content, version).await; + } + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let mut docs = self.documents.write().await; + docs.remove(¶ms.text_document.uri); + } + + async fn semantic_tokens_full( + &self, + params: SemanticTokensParams, + ) -> Result> { + let docs = self.documents.read().await; + let Some(doc) = docs.get(¶ms.text_document.uri) else { + return Ok(None); + }; + + let tokens = token_types_for_source(&doc.content); + Ok(Some(SemanticTokensResult::Tokens(SemanticTokens { + result_id: None, + data: tokens, + }))) + } + + async fn hover(&self, params: HoverParams) -> Result> { + let docs = self.documents.read().await; + let Some(doc) = docs.get(¶ms.text_document_position_params.text_document.uri) else { + return Ok(None); + }; + + let position = params.text_document_position_params.position; + let hover_info = get_hover_info(&doc.content, position); + + Ok(hover_info.map(|content| Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: content, + }), + range: None, + })) + } + + async fn completion(&self, params: CompletionParams) -> Result> { + let docs = self.documents.read().await; + let Some(doc) = docs.get(¶ms.text_document_position.text_document.uri) else { + return Ok(None); + }; + + let position = params.text_document_position.position; + let completions = get_context_aware_completions(&doc.content, position); + Ok(Some(CompletionResponse::Array(completions))) + } + + async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { + let docs = self.documents.read().await; + let Some(doc) = docs.get(¶ms.text_document.uri) else { + return Ok(None); + }; + + let formatted = format_source(&doc.content, ¶ms.options); + + // If formatting produced changes, return a single edit replacing the whole document + if formatted != doc.content { + let line_count = doc.content.lines().count() as u32; + let last_line_len = doc.content.lines().last().map(|l| l.len()).unwrap_or(0) as u32; + + Ok(Some(vec![TextEdit { + range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: line_count, + character: last_line_len, + }, + }, + new_text: formatted, + }])) + } else { + Ok(None) + } + } + + async fn goto_definition( + &self, + params: GotoDefinitionParams, + ) -> Result> { + let uri = params + .text_document_position_params + .text_document + .uri + .clone(); + let position = params.text_document_position_params.position; + + let docs = self.documents.read().await; + let Some(doc) = docs.get(&uri) else { + return Ok(None); + }; + + // Find the word at the cursor position + let Some(word) = get_word_at_position(&doc.content, position) else { + return Ok(None); + }; + + // Parse and analyze to get symbol table + let Ok(program) = zlup::parse(&doc.content) else { + return Ok(None); + }; + + let mut analyzer = SemanticAnalyzer::new(); + // Analyze even if there are errors - we still want partial symbol info + let _ = analyzer.analyze(&program); + + // Look up the symbol + if let Some(symbol) = analyzer.symbols.lookup(&word) + && let Some(loc) = &symbol.location + { + let range = location_to_range(loc); + return Ok(Some(GotoDefinitionResponse::Scalar(Location { + uri, + range, + }))); + } + + Ok(None) + } +} + +/// Convert a SourceLocation to an LSP Range +fn location_to_range(loc: &SourceLocation) -> Range { + Range { + start: Position { + line: loc.line.saturating_sub(1), + character: loc.column.saturating_sub(1), + }, + end: Position { + line: loc.end_line.saturating_sub(1), + character: loc.end_column.saturating_sub(1), + }, + } +} + +/// Extract line and column from a pest error message +fn extract_pest_location(error_msg: &str) -> (u32, u32) { + // Pest errors typically contain " --> line:column" + if let Some(pos) = error_msg.find(" --> ") { + let rest = &error_msg[pos + 5..]; + if let Some(colon) = rest.find(':') { + let line_str = &rest[..colon]; + let col_end = rest[colon + 1..] + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len() - colon - 1); + let col_str = &rest[colon + 1..colon + 1 + col_end]; + + if let (Ok(line), Ok(col)) = (line_str.parse::(), col_str.parse::()) { + return (line, col); + } + } + } + (1, 1) +} + +/// Convert pest parse errors to user-friendly messages +fn friendly_parse_error(pest_message: &str) -> String { + if pest_message.contains("expected identifier") { + "expected an identifier".to_string() + } else if pest_message.contains("expected type_expr") { + "expected a type".to_string() + } else if pest_message.contains("expected expr") { + "expected an expression".to_string() + } else if pest_message.contains("expected statement") { + "expected a statement".to_string() + } else if pest_message.contains("expected \"(\"") { + "expected '('".to_string() + } else if pest_message.contains("expected \")\"") { + "expected ')'".to_string() + } else if pest_message.contains("expected \"{\"") { + "expected '{'".to_string() + } else if pest_message.contains("expected \"}\"") { + "expected '}'".to_string() + } else if pest_message.contains("expected \";\"") { + "expected ';'".to_string() + } else if pest_message.contains("expected assign_op") { + "unexpected token - expected assignment or operator".to_string() + } else if pest_message.contains("expected top_level_decl") { + "expected a function, constant, or type declaration".to_string() + } else if pest_message.contains("expected EOI") { + "unexpected content after end of file".to_string() + } else { + // Take only the first line of the error + pest_message + .lines() + .next() + .unwrap_or(pest_message) + .to_string() + } +} + +/// Get the word at a given position in the source +fn get_word_at_position(source: &str, position: Position) -> Option { + let lines: Vec<&str> = source.lines().collect(); + let line = lines.get(position.line as usize)?; + let char_pos = position.character as usize; + + if char_pos > line.len() { + return None; + } + + // Find the word boundaries + let start = line[..char_pos] + .rfind(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + 1) + .unwrap_or(0); + let end = line[char_pos..] + .find(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + char_pos) + .unwrap_or(line.len()); + + if start >= end { + return None; + } + + Some(line[start..end].to_string()) +} + +/// Get hover information for a position in the source +fn get_hover_info(source: &str, position: Position) -> Option { + let word = get_word_at_position(source, position)?; + + // Return documentation for known items + match word.as_str() { + // Keywords + "fn" => Some("**fn** - Function declaration\n\n```zlup\nfn name(params) -> ReturnType { ... }\n```".to_string()), + "mut" => Some("**mut** - Mutable binding modifier\n\n```zlup\nmut x := 42; // type inferred\nmut x: i32 = 42; // explicit type\n```".to_string()), + "if" => Some("**if** - Conditional expression\n\n```zlup\nif condition { ... } else { ... }\n```".to_string()), + "for" => Some("**for** - Bounded iteration loop (NASA Power of 10)\n\n```zlup\nfor i in 0..10 { ... }\n```".to_string()), + "return" => Some("**return** - Return from function\n\n```zlup\nreturn value;\n```".to_string()), + "defer" => Some("**defer** - Execute at scope exit\n\n```zlup\ndefer resource.close();\n```".to_string()), + "errdefer" => Some("**errdefer** - Execute at scope exit on error\n\n```zlup\nerrdefer cleanup();\n```".to_string()), + + // Types + "unit" => Some("**unit** - Unit type (single value, used for functions with no meaningful return)".to_string()), + "bool" => Some("**bool** - Boolean type (true/false)".to_string()), + "i32" => Some("**i32** - 32-bit signed integer".to_string()), + "i64" => Some("**i64** - 64-bit signed integer".to_string()), + "u32" => Some("**u32** - 32-bit unsigned integer".to_string()), + "u64" => Some("**u64** - 64-bit unsigned integer".to_string()), + "f32" => Some("**f32** - 32-bit floating point".to_string()), + "f64" => Some("**f64** - 64-bit floating point".to_string()), + "usize" => Some("**usize** - Platform-dependent unsigned integer (array indices)".to_string()), + "QubitArray" => Some("**QubitArray** - Array of qubits for quantum operations".to_string()), + + // Built-in functions + "qalloc" => Some("**qalloc(n)** - Allocate n qubits\n\n```zlup\nvar q = qalloc(4); // Allocate 4 qubits\n```".to_string()), + "print" => Some("**print(...)** - Print to stdout".to_string()), + + // Quantum gates + "h" | "H" => Some("**H** (Hadamard) - Creates superposition\n\n```zlup\nq.h(0); // Apply H to qubit 0\n```".to_string()), + "x" | "X" => Some("**X** (Pauli-X) - Bit flip gate\n\n```zlup\nq.x(0); // Apply X to qubit 0\n```".to_string()), + "y" | "Y" => Some("**Y** (Pauli-Y) - Y rotation gate\n\n```zlup\nq.y(0); // Apply Y to qubit 0\n```".to_string()), + "z" | "Z" => Some("**Z** (Pauli-Z) - Phase flip gate\n\n```zlup\nq.z(0); // Apply Z to qubit 0\n```".to_string()), + "cx" | "CX" | "cnot" | "CNOT" => Some("**CX/CNOT** - Controlled-X (CNOT) gate\n\n```zlup\nq.cx(0, 1); // Control: 0, Target: 1\n```".to_string()), + "cz" | "CZ" => Some("**CZ** - Controlled-Z gate\n\n```zlup\nq.cz(0, 1); // Apply CZ between qubits 0 and 1\n```".to_string()), + "rx" | "RX" => Some("**RX(theta)** - X-axis rotation\n\n```zlup\nq.rx(0, 3.14159); // Rotate qubit 0 by pi\n```".to_string()), + "ry" | "RY" => Some("**RY(theta)** - Y-axis rotation\n\n```zlup\nq.ry(0, 3.14159); // Rotate qubit 0 by pi\n```".to_string()), + "rz" | "RZ" => Some("**RZ(theta)** - Z-axis rotation\n\n```zlup\nq.rz(0, 3.14159); // Rotate qubit 0 by pi\n```".to_string()), + "t" | "T" => Some("**T** - T gate (pi/4 phase)\n\n```zlup\nt q[0]; // Apply T to qubit 0\n```".to_string()), + "sz" | "SZ" => Some("**SZ** - S gate (pi/2 phase, sqrt of Z)\n\n```zlup\nsz q[0]; // Apply S to qubit 0\n```".to_string()), + "mz" | "measure" => Some("**mz** - Measure qubit in Z basis\n\n```zlup\nconst result = mz(u1) q[0]; // Measure qubit 0\n```".to_string()), + "pz" => Some("**pz** - Prepare qubits in |0⟩ state\n\n```zlup\npz q; // Prepare all qubits\npz {q[0], q[1]}; // Prepare specific qubits\n```".to_string()), + + _ => None, + } +} + +/// Get context-aware completions based on cursor position +fn get_context_aware_completions(source: &str, position: Position) -> Vec { + let lines: Vec<&str> = source.lines().collect(); + let Some(line) = lines.get(position.line as usize) else { + return get_default_completions(); + }; + + let char_pos = position.character as usize; + let prefix = if char_pos <= line.len() { + &line[..char_pos] + } else { + line + }; + + // Check if we're completing after a '.' + if let Some(dot_pos) = prefix.rfind('.') { + // Get the identifier before the dot + let before_dot = &prefix[..dot_pos]; + let ident_start = before_dot + .rfind(|c: char| !c.is_alphanumeric() && c != '_' && c != '[' && c != ']') + .map(|i| i + 1) + .unwrap_or(0); + let identifier = before_dot[ident_start..].trim(); + + // Check if this looks like an allocator (contains qalloc or common allocator names) + if is_likely_allocator(source, identifier) { + return get_allocator_completions(); + } + + // Generic field/method completions + return get_method_completions(); + } + + // Check if we're in a type context (after ':' or '->') + let trimmed = prefix.trim_end(); + if trimmed.ends_with(':') || trimmed.ends_with("->") { + return get_type_completions(); + } + + // Default: keywords, types, and functions + get_default_completions() +} + +/// Check if an identifier is likely an allocator variable +fn is_likely_allocator(source: &str, identifier: &str) -> bool { + // Strip array indexing like q[0] -> q + let base_ident = identifier.split('[').next().unwrap_or(identifier); + + // Check if there's a qalloc assignment for this identifier + let pattern = format!("{} = qalloc", base_ident); + if source.contains(&pattern) { + return true; + } + + // Check for .child() assignment + let child_pattern = format!("{} = ", base_ident); + for line in source.lines() { + if line.contains(&child_pattern) && line.contains(".child(") { + return true; + } + } + + // Common allocator variable names + matches!( + base_ident, + "q" | "qubits" | "base" | "data" | "ancilla" | "alloc" | "allocator" + ) +} + +/// Get completions for allocator methods +fn get_allocator_completions() -> Vec { + let methods = [ + ("child", "Create child allocator", "child(${1:size})"), + ("release", "Release allocated qubits", "release()"), + ]; + + methods + .iter() + .map(|(label, detail, snippet)| CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::METHOD), + detail: Some(detail.to_string()), + insert_text: Some(snippet.to_string()), + insert_text_format: Some(InsertTextFormat::SNIPPET), + ..Default::default() + }) + .collect() +} + +/// Get generic method completions +fn get_method_completions() -> Vec { + // Combine allocator methods with common methods + let mut items = get_allocator_completions(); + + // Add common struct methods/fields + let common = [ + ("len", "Get length", "len()"), + ("is_empty", "Check if empty", "is_empty()"), + ]; + + for (label, detail, snippet) in common { + items.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::METHOD), + detail: Some(detail.to_string()), + insert_text: Some(snippet.to_string()), + insert_text_format: Some(InsertTextFormat::SNIPPET), + ..Default::default() + }); + } + + items +} + +/// Get type completions +fn get_type_completions() -> Vec { + let types = [ + ("unit", "Unit type (no meaningful return value)"), + ("bool", "Boolean type"), + ("u1", "1-bit unsigned (measurement result)"), + ("u8", "8-bit unsigned integer"), + ("u16", "16-bit unsigned integer"), + ("u32", "32-bit unsigned integer"), + ("u64", "64-bit unsigned integer"), + ("u128", "128-bit unsigned integer"), + ("usize", "Platform-sized unsigned integer"), + ("i8", "8-bit signed integer"), + ("i16", "16-bit signed integer"), + ("i32", "32-bit signed integer"), + ("i64", "64-bit signed integer"), + ("i128", "128-bit signed integer"), + ("f32", "32-bit floating point"), + ("f64", "64-bit floating point"), + ("a64", "64-bit angle type"), + ("qubit", "Qubit type"), + ("bit", "Classical bit"), + ]; + + types + .iter() + .map(|(label, detail)| CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::TYPE_PARAMETER), + detail: Some(detail.to_string()), + ..Default::default() + }) + .collect() +} + +/// Format source code according to formatting options +fn format_source(source: &str, options: &FormattingOptions) -> String { + let indent_str = if options.insert_spaces { + " ".repeat(options.tab_size as usize) + } else { + "\t".to_string() + }; + + let mut result = String::new(); + let mut indent_level: i32 = 0; + let mut in_string = false; + let mut prev_char = '\0'; + + for line in source.lines() { + let trimmed = line.trim(); + + // Skip empty lines but preserve one blank line + if trimmed.is_empty() { + if !result.ends_with("\n\n") { + result.push('\n'); + } + continue; + } + + // Adjust indent for closing braces at start of line + let starts_with_close = trimmed.starts_with('}') || trimmed.starts_with(')'); + if starts_with_close && indent_level > 0 { + indent_level -= 1; + } + + // Write indentation + for _ in 0..indent_level { + result.push_str(&indent_str); + } + + // Format the line content + let formatted_line = format_line(trimmed); + result.push_str(&formatted_line); + result.push('\n'); + + // Adjust indent for next line based on braces in this line + for ch in trimmed.chars() { + match ch { + '"' if prev_char != '\\' => in_string = !in_string, + '{' | '(' if !in_string => indent_level += 1, + '}' | ')' if !in_string && !starts_with_close => { + indent_level = (indent_level - 1).max(0); + } + _ => {} + } + prev_char = ch; + } + } + + // Remove trailing whitespace from each line and ensure single newline at end + let result: String = result + .lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n"); + + if result.is_empty() { + result + } else { + result + "\n" + } +} + +/// Format a single line (spacing around operators, etc.) +fn format_line(line: &str) -> String { + let mut result = String::new(); + let mut chars = line.chars().peekable(); + let mut in_string = false; + let mut prev_char = '\0'; + + while let Some(ch) = chars.next() { + // Track string state + if ch == '"' && prev_char != '\\' { + in_string = !in_string; + } + + if in_string { + result.push(ch); + prev_char = ch; + continue; + } + + match ch { + // Ensure space after comma + ',' => { + result.push(','); + if chars.peek() != Some(&' ') && chars.peek() != Some(&'\n') { + result.push(' '); + } + } + // Ensure space around '=' (but not ==, !=, <=, >=, =>) + '=' => { + let next = chars.peek().copied(); + if next == Some('=') || next == Some('>') { + // Part of ==, =>, don't add space before + if !result.ends_with(' ') + && !result.ends_with('!') + && !result.ends_with('<') + && !result.ends_with('>') + { + result.push(' '); + } + result.push('='); + } else if prev_char == '!' + || prev_char == '<' + || prev_char == '>' + || prev_char == '=' + { + // Part of !=, <=, >=, == + result.push('='); + if chars.peek() != Some(&' ') { + result.push(' '); + } + } else { + // Standalone = + if !result.ends_with(' ') { + result.push(' '); + } + result.push('='); + if chars.peek() != Some(&' ') && chars.peek().is_some() { + result.push(' '); + } + } + } + // Ensure space after colon in type annotations (but not ::) + ':' => { + result.push(':'); + if chars.peek() != Some(&':') + && chars.peek() != Some(&' ') + && chars.peek().is_some() + { + result.push(' '); + } + } + // Collapse multiple spaces + ' ' => { + if !result.ends_with(' ') { + result.push(' '); + } + } + _ => result.push(ch), + } + + prev_char = ch; + } + + result +} + +/// Get default completions (keywords, types, functions) +fn get_default_completions() -> Vec { + let mut items = Vec::new(); + + // Keywords + let keywords = [ + ( + "fn", + "Function declaration", + "fn ${1:name}(${2:params}) -> ${3:unit} {\n\treturn unit;\n}", + ), + ("mut", "Mutable binding", "mut ${1:name} := ${0:value};"), + ("if", "Conditional", "if ${1:condition} {\n\t$0\n}"), + ("else", "Else branch", "else {\n\t$0\n}"), + ( + "for", + "Bounded for loop", + "for ${1:i} in ${2:0}..${3:n} {\n\t$0\n}", + ), + ("return", "Return statement", "return ${0:value};"), + ("defer", "Defer statement", "defer ${0:expr};"), + ("errdefer", "Error defer", "errdefer ${0:expr};"), + ( + "struct", + "Struct type", + "struct {\n\t${1:field}: ${2:type},\n}", + ), + ("enum", "Enum type", "enum {\n\t${1:Variant},\n}"), + ( + "union", + "Tagged union", + "union(enum) {\n\t${1:Variant}: ${2:type},\n}", + ), + ("error", "Error set", "error {\n\t${1:ErrorName},\n}"), + ("set", "Set literal", "set { ${0:elements} }"), + ]; + + for (label, detail, snippet) in keywords { + items.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some(detail.to_string()), + insert_text: Some(snippet.to_string()), + insert_text_format: Some(InsertTextFormat::SNIPPET), + ..Default::default() + }); + } + + // Types + let types = [ + "unit", + "bool", + "i32", + "i64", + "u32", + "u64", + "f32", + "f64", + "usize", + "QubitArray", + ]; + + for ty in types { + items.push(CompletionItem { + label: ty.to_string(), + kind: Some(CompletionItemKind::TYPE_PARAMETER), + detail: Some("Built-in type".to_string()), + ..Default::default() + }); + } + + // Quantum functions + let quantum_funcs = [ + ("qalloc", "Allocate qubits", "qalloc(${1:n})"), + ("h", "Hadamard gate", "h(${1:qubit})"), + ("x", "Pauli-X gate", "x(${1:qubit})"), + ("y", "Pauli-Y gate", "y(${1:qubit})"), + ("z", "Pauli-Z gate", "z(${1:qubit})"), + ("cx", "CNOT gate", "cx(${1:control}, ${2:target})"), + ("cz", "CZ gate", "cz(${1:q1}, ${2:q2})"), + ("rx", "RX rotation", "rx(${1:qubit}, ${2:angle})"), + ("ry", "RY rotation", "ry(${1:qubit}, ${2:angle})"), + ("rz", "RZ rotation", "rz(${1:qubit}, ${2:angle})"), + ("mz", "Measure Z", "mz(${1:qubit})"), + ]; + + for (label, detail, snippet) in quantum_funcs { + items.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some(detail.to_string()), + insert_text: Some(snippet.to_string()), + insert_text_format: Some(InsertTextFormat::SNIPPET), + ..Default::default() + }); + } + + items +} + +#[tokio::main] +async fn main() { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = LspService::new(ZlupsServer::new); + Server::new(stdin, stdout, socket).serve(service).await; +} diff --git a/exp/zlup/src/lsp/semantic_tokens.rs b/exp/zlup/src/lsp/semantic_tokens.rs new file mode 100644 index 000000000..0eda82d08 --- /dev/null +++ b/exp/zlup/src/lsp/semantic_tokens.rs @@ -0,0 +1,316 @@ +//! Semantic token support for syntax highlighting + +use once_cell::sync::Lazy; +use tower_lsp::lsp_types::*; + +/// Token types used for semantic highlighting +pub static LEGEND: Lazy = Lazy::new(|| SemanticTokensLegend { + token_types: vec![ + SemanticTokenType::KEYWORD, + SemanticTokenType::TYPE, + SemanticTokenType::FUNCTION, + SemanticTokenType::VARIABLE, + SemanticTokenType::NUMBER, + SemanticTokenType::STRING, + SemanticTokenType::OPERATOR, + SemanticTokenType::COMMENT, + SemanticTokenType::PARAMETER, + SemanticTokenType::PROPERTY, + ], + token_modifiers: vec![ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DEFINITION, + SemanticTokenModifier::READONLY, + ], +}); + +// Token type indices (must match LEGEND order) +const TT_KEYWORD: u32 = 0; +const TT_TYPE: u32 = 1; +const TT_FUNCTION: u32 = 2; +const TT_VARIABLE: u32 = 3; +const TT_NUMBER: u32 = 4; +const TT_STRING: u32 = 5; +const TT_OPERATOR: u32 = 6; +const TT_COMMENT: u32 = 7; +#[allow(dead_code)] +const TT_PARAMETER: u32 = 8; +#[allow(dead_code)] +const TT_PROPERTY: u32 = 9; + +/// Keywords in Zlup +const KEYWORDS: &[&str] = &[ + "fn", + "mut", + "if", + "else", + "for", + "return", + "defer", + "errdefer", + "struct", + "enum", + "union", + "error", + "try", + "catch", + "orelse", + "break", + "continue", + "comptime", + "inline", + "pub", + "and", + "or", + "not", + "true", + "false", + "null", + "undefined", +]; + +/// Built-in types +const TYPES: &[&str] = &[ + "unit", + "bool", + "i8", + "i16", + "i32", + "i64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "usize", + "isize", + "QubitArray", + "Qubit", +]; + +/// Quantum gate names (functions) +const GATES: &[&str] = &[ + "h", "H", "x", "X", "y", "Y", "z", "Z", "s", "S", "t", "T", "cx", "CX", "cnot", "CNOT", "cz", + "CZ", "cy", "CY", "rx", "RX", "ry", "RY", "rz", "RZ", "swap", "SWAP", "ccx", "CCX", "toffoli", + "mz", "mx", "my", "measure", "pz", "qalloc", +]; + +/// Built-in functions +const BUILTINS: &[&str] = &["print", "println", "assert", "unreachable"]; + +/// Generate semantic tokens for source code +pub fn token_types_for_source(source: &str) -> Vec { + let mut tokens = Vec::new(); + let mut prev_line = 0u32; + let mut prev_char = 0u32; + + for (line_num, line) in source.lines().enumerate() { + let line_num = line_num as u32; + let mut char_idx = 0u32; + let chars: Vec = line.chars().collect(); + + while (char_idx as usize) < chars.len() { + let c = chars[char_idx as usize]; + + // Skip whitespace + if c.is_whitespace() { + char_idx += 1; + continue; + } + + // Line comments + if c == '/' && chars.get(char_idx as usize + 1) == Some(&'/') { + let len = (chars.len() - char_idx as usize) as u32; + add_token( + &mut tokens, + line_num, + char_idx, + len, + TT_COMMENT, + &mut prev_line, + &mut prev_char, + ); + break; // Rest of line is comment + } + + // String literals + if c == '"' { + let start = char_idx; + char_idx += 1; + while (char_idx as usize) < chars.len() { + let ch = chars[char_idx as usize]; + if ch == '"' { + char_idx += 1; + break; + } + if ch == '\\' { + char_idx += 1; // Skip escape + } + char_idx += 1; + } + let len = char_idx - start; + add_token( + &mut tokens, + line_num, + start, + len, + TT_STRING, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Character literals + if c == '\'' { + let start = char_idx; + char_idx += 1; + while (char_idx as usize) < chars.len() { + let ch = chars[char_idx as usize]; + if ch == '\'' { + char_idx += 1; + break; + } + if ch == '\\' { + char_idx += 1; + } + char_idx += 1; + } + let len = char_idx - start; + add_token( + &mut tokens, + line_num, + start, + len, + TT_STRING, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Numbers + if c.is_ascii_digit() { + let start = char_idx; + while (char_idx as usize) < chars.len() { + let ch = chars[char_idx as usize]; + if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' { + char_idx += 1; + } else { + break; + } + } + let len = char_idx - start; + add_token( + &mut tokens, + line_num, + start, + len, + TT_NUMBER, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Identifiers and keywords + if c.is_alphabetic() || c == '_' { + let start = char_idx; + while (char_idx as usize) < chars.len() { + let ch = chars[char_idx as usize]; + if ch.is_alphanumeric() || ch == '_' { + char_idx += 1; + } else { + break; + } + } + let len = char_idx - start; + let word: String = chars[start as usize..char_idx as usize].iter().collect(); + + let token_type = if KEYWORDS.contains(&word.as_str()) { + TT_KEYWORD + } else if TYPES.contains(&word.as_str()) { + TT_TYPE + } else if GATES.contains(&word.as_str()) || BUILTINS.contains(&word.as_str()) { + TT_FUNCTION + } else { + TT_VARIABLE + }; + + add_token( + &mut tokens, + line_num, + start, + len, + token_type, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Operators (multi-char) + if is_operator_char(c) { + let start = char_idx; + while (char_idx as usize) < chars.len() + && is_operator_char(chars[char_idx as usize]) + { + char_idx += 1; + } + let len = char_idx - start; + add_token( + &mut tokens, + line_num, + start, + len, + TT_OPERATOR, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Single punctuation (skip) + char_idx += 1; + } + } + + tokens +} + +fn is_operator_char(c: char) -> bool { + matches!( + c, + '+' | '-' | '*' | '/' | '%' | '=' | '!' | '<' | '>' | '&' | '|' | '^' | '~' | '@' + ) +} + +fn add_token( + tokens: &mut Vec, + line: u32, + character: u32, + length: u32, + token_type: u32, + prev_line: &mut u32, + prev_char: &mut u32, +) { + // LSP semantic tokens use delta encoding + let delta_line = line - *prev_line; + let delta_start = if delta_line == 0 { + character - *prev_char + } else { + character + }; + + tokens.push(SemanticToken { + delta_line, + delta_start, + length, + token_type, + token_modifiers_bitset: 0, + }); + + *prev_line = line; + *prev_char = character; +} diff --git a/exp/zlup/src/main.rs b/exp/zlup/src/main.rs new file mode 100644 index 000000000..d4ff362e0 --- /dev/null +++ b/exp/zlup/src/main.rs @@ -0,0 +1,2192 @@ +//! Zluppy CLI - A Zig/SLR/NASA Power of 10 quantum programming language. +//! +//! ## Usage +//! +//! ```bash +//! # Initialize a new project +//! zlup init my-project +//! +//! # Build project using zlup.toml +//! zlup build +//! +//! # Compile to SLR-AST JSON (Python/PECOS bridge) +//! zlup compile program.zlp --target slr -o output.json +//! +//! # Compile to OpenQASM 2.0 (simulators/hardware) +//! zlup compile program.zlp --target qasm -o output.qasm +//! +//! # Compile to HUGR (requires --features hugr) +//! zlup compile program.zlp --target hugr -o output.hugr +//! +//! # Check without compiling (semantic validation) +//! zlup check program.zlp +//! +//! # Check with strict mode (NASA Power of 10) +//! zlup check program.zlp --strict +//! +//! # Parse and dump AST (for debugging) +//! zlup parse program.zlp +//! +//! # Analyze parallelism opportunities +//! zlup analyze program.zlp +//! zlup analyze program.zlp --format json --verbose +//! ``` + +// Experimental - suppress warnings during development +// The unused_assignments warning is triggered by miette's derive macro +// for the #[source_code], #[label], and #[help] fields +#![allow(dead_code, unused_assignments)] + +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Parser, Subcommand, ValueEnum}; +use miette::{Diagnostic, NamedSource, SourceSpan}; + +use zlup::codegen::SlrCodegen; +use zlup::config::{CONFIG_FILE_NAME, Config, TargetConfig}; +use zlup::semantic::SemanticAnalyzer; + +// ============================================================================= +// CLI Structure +// ============================================================================= + +/// Zluppy - A Zig/SLR/NASA Power of 10 quantum programming language. +/// +/// Zluppy solves the same problems as Guppy but through a different lens: +/// explicit over implicit, low-level but safe, simple and obvious. +#[derive(Parser)] +#[command(name = "zlup")] +#[command(version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new Zlup project with zlup.toml. + Init { + /// Project name (also creates directory with this name) + #[arg(value_name = "NAME")] + name: String, + + /// Create project in current directory instead of new directory + #[arg(long)] + here: bool, + }, + + /// Build project using zlup.toml configuration. + Build { + /// Override strict mode setting + #[arg(long)] + strict: Option, + + /// Override execution target + #[arg(short, long, value_enum)] + target: Option, + + /// Override output format + #[arg(short, long, value_enum)] + format: Option, + + /// Build mode (debug/release) + #[arg(short, long, value_enum, default_value = "debug")] + mode: Mode, + + /// Emit compact output (no pretty-printing) + #[arg(long)] + compact: bool, + }, + + /// Compile a Zluppy source file to a target format. + Compile { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Output file (use - for stdout, default: derived from input) + #[arg(short, long, value_name = "FILE")] + output: Option, + + /// Execution target (what you're compiling for) + #[arg(short, long, value_enum, default_value = "simulator")] + target: Target, + + /// Output format (how to serialize) + #[arg(short, long, value_enum, default_value = "slr")] + format: Format, + + /// Build mode (debug/release) + #[arg(short, long, value_enum, default_value = "debug")] + mode: Mode, + + /// Emit compact output (no pretty-printing) + #[arg(long)] + compact: bool, + + /// Override strict mode from target+mode defaults + #[arg(long)] + strict: Option, + + /// Override log level (elide logs below this level) + /// Values: trace=0, debug=100, info=200, warn=300, error=400 + #[arg(long, value_name = "LEVEL")] + log_level: Option, + + /// Completely elide sim.* commands (no barrier). + /// By default, hardware targets emit barriers for sim commands to + /// preserve ordering. This flag removes them entirely for max optimization. + #[arg(long)] + elide_sim: bool, + }, + + /// Check a Zluppy source file for errors without compiling. + Check { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Enable strict mode (NASA Power of 10 checks) + #[arg(long)] + strict: bool, + }, + + /// Parse a Zluppy source file and dump the AST (for debugging). + Parse { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Output format + #[arg(short, long, value_enum, default_value = "debug")] + format: AstFormat, + }, + + /// Format a Zluppy source file. + #[command(name = "fmt")] + Format { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Write output to file instead of stdout + #[arg(short, long)] + write: bool, + + /// Check if file is formatted (exit 1 if not) + #[arg(long)] + check: bool, + }, + + /// Lint a Zluppy source file for style and best practices. + Lint { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Lint configuration level (default is strict for safety) + #[arg(short, long, value_enum, default_value = "strict")] + level: LintLevel, + + /// Treat warnings as errors + #[arg(long)] + deny_warnings: bool, + + /// Output format + #[arg(short, long, value_enum, default_value = "pretty")] + format: LintFormat, + + /// Apply safe fixes automatically + #[arg(long)] + fix: bool, + + /// Also apply unsafe fixes (requires --fix) + #[arg(long, requires = "fix")] + unsafe_fixes: bool, + + /// Show diff of fixes without applying them + #[arg(long)] + diff: bool, + + /// Show fix statistics only (no diagnostics output) + #[arg(long)] + statistics: bool, + }, + + /// Evaluate a Zluppy expression or small program (playground mode). + /// + /// Examples: + /// zlup eval "2 + 3" + /// zlup eval "std.pi * 2" + /// echo "x := 5; x * 2" | zlup eval - + Eval { + /// Expression to evaluate (or - for stdin) + #[arg(value_name = "EXPR")] + expr: String, + + /// Show AST and intermediate steps + #[arg(long)] + verbose: bool, + }, + + /// Analyze a Zluppy source file for parallelism opportunities. + /// + /// This command performs static analysis to identify: + /// - Qubit allocator lifetimes and scopes + /// - Operation dependencies (qubit and data dependencies) + /// - Parallel execution layers (operations that can run simultaneously) + /// + /// Examples: + /// zlup analyze program.zlp + /// zlup analyze program.zlp --format json + /// zlup analyze program.zlp --verbose + Analyze { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Output format + #[arg(short, long, value_enum, default_value = "text")] + format: AnalyzeFormat, + + /// Show detailed dependency graph + #[arg(long, short)] + verbose: bool, + }, + + /// Generate documentation from doc comments in a Zluppy source file. + /// + /// Examples: + /// zlup doc program.zlp + /// zlup doc program.zlp -o docs.md + /// zlup doc program.zlp --all + Doc { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Output file (default: stdout) + #[arg(short, long, value_name = "FILE")] + output: Option, + + /// Include private (non-pub) items + #[arg(long)] + all: bool, + }, + + /// Run tests defined in a Zluppy source file. + /// + /// Tests are defined with `test "name" { ... }` blocks. + /// + /// Examples: + /// zlup test program.zlp + /// zlup test program.zlp --filter "addition" + /// zlup test program.zlp --verbose + Test { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Only run tests matching this pattern + #[arg(long, value_name = "PATTERN")] + filter: Option, + + /// Enable strict mode (NASA Power of 10) + #[arg(long)] + strict: bool, + + /// Print verbose output + #[arg(long, short)] + verbose: bool, + }, +} + +/// Lint configuration levels. +#[derive(Clone, Copy, ValueEnum)] +enum LintLevel { + /// Minimal checks (only unused variable/measurement) + Minimal, + /// Relaxed checks (warnings instead of errors) + Relaxed, + /// Strict checks (NASA Power of 10 enforcement) - DEFAULT + Strict, +} + +/// Lint output formats. +#[derive(Clone, Copy, ValueEnum)] +enum LintFormat { + /// Human-readable format with colors + Pretty, + /// JSON format for tooling integration + Json, + /// Compact one-line-per-diagnostic format + Compact, +} + +/// Execution target - what you're compiling for (affects semantics and passes). +#[derive(Clone, Copy, ValueEnum, Default, Debug)] +enum Target { + /// Simulator: full debug info, relaxed constraints, keep simulation constructs + #[default] + Simulator, + /// Hardware: strict constraints, drop simulation artifacts, enforce gate sets + Hardware, + /// Emulator: hardware-like constraints but with simulation visibility + Emulator, +} + +impl Target { + /// Whether this target implies strict mode by default. + fn default_strict(&self) -> bool { + match self { + Target::Simulator => false, + Target::Hardware => true, + Target::Emulator => true, + } + } + + /// Default log elision level for this target. + fn default_log_elision(&self) -> Option { + match self { + Target::Simulator => None, // Keep all logs + Target::Hardware => Some(300), // Warn and above only + Target::Emulator => Some(200), // Info and above + } + } + + /// How sim.* commands should be handled for this target. + /// + /// Simulator commands (noise control, etc.) are only meaningful for + /// the simulator target. For hardware and emulator, they emit barriers + /// by default to preserve ordering semantics. + fn sim_mode(&self) -> zlup::codegen::slr::SimMode { + use zlup::codegen::slr::SimMode; + match self { + Target::Simulator => SimMode::Emit, // Output actual sim commands + Target::Hardware => SimMode::Barrier, // Emit barrier to preserve ordering + Target::Emulator => SimMode::Barrier, // Emit barrier to preserve ordering + } + } +} + +/// Output format - how to serialize the compiled output. +#[derive(Clone, Copy, ValueEnum, Default, Debug)] +enum Format { + /// SLR-AST JSON (Python/PECOS bridge) + #[default] + Slr, + /// PHIR-JSON format (PECOS simulator targeting) - see pecos-phir-json spec v0.1.0 + PhirJson, + /// OpenQASM 2.0 (simulators and hardware) + Qasm, + /// HUGR (hardware/experiments) - requires --features hugr + #[cfg(feature = "hugr")] + Hugr, +} + +/// AST output formats for parse command. +#[derive(Clone, Copy, ValueEnum)] +enum AstFormat { + /// Rust Debug format + Debug, + /// JSON format + Json, +} + +/// Output formats for analyze command. +#[derive(Clone, Copy, ValueEnum, Default)] +enum AnalyzeFormat { + /// Human-readable text output + #[default] + Text, + /// JSON output for tooling integration + Json, +} + +/// Build mode - optimization and debug level. +/// +/// Combined with Target, this controls the full compilation behavior. +/// Target controls *what* you're building for, Mode controls *how* optimized. +#[derive(Clone, Copy, ValueEnum, Default, Debug)] +enum Mode { + /// Debug (default): all logs kept, no optimizations, permissive + #[default] + Debug, + + /// Release: elide debug/trace logs, enable optimizations, stricter checks + Release, +} + +impl Mode { + /// Log elision adjustment for this mode (added to target's default). + fn log_elision_adjustment(&self) -> Option { + match self { + Mode::Debug => None, // Don't elide beyond target default + Mode::Release => Some(100), // Bump elision by one level + } + } + + /// Whether this mode implies stricter checks. + fn strict_bias(&self) -> bool { + match self { + Mode::Debug => false, + Mode::Release => true, + } + } +} + +/// Compute effective settings from target + mode combination. +fn effective_settings(target: Target, mode: Mode) -> (bool, Option) { + // Strict: either target or mode can enable it + let strict = target.default_strict() || mode.strict_bias(); + + // Log elision: start with target default, then apply mode adjustment + let log_elision = match (target.default_log_elision(), mode.log_elision_adjustment()) { + (Some(t), Some(m)) => Some(t.max(m)), // Take the higher (more elision) + (Some(t), None) => Some(t), + (None, Some(m)) => Some(m), + (None, None) => None, + }; + + (strict, log_elision) +} + +// ============================================================================= +// Error Handling +// ============================================================================= + +/// CLI error with source context. +/// Fields like `src`, `span`, and `help` are used by miette's Diagnostic derive, +/// not directly in our code. +#[derive(Debug, Diagnostic, thiserror::Error)] +enum CliError { + #[error("failed to read file: {path}")] + #[diagnostic(code(zlup::io::read))] + ReadError { + path: String, + #[source] + source: io::Error, + }, + + #[error("failed to write file: {path}")] + #[diagnostic(code(zlup::io::write))] + WriteError { + path: String, + #[source] + source: io::Error, + }, + + #[error("parse error")] + #[diagnostic(code(zlup::parse))] + ParseError { + #[source_code] + src: NamedSource, + #[label("error here")] + span: SourceSpan, + #[help] + help: String, + }, + + #[error("semantic error: {message}")] + #[diagnostic(code(zlup::semantic))] + SemanticError { + message: String, + #[source_code] + src: NamedSource, + #[label("{message}")] + span: SourceSpan, + }, + + #[error("codegen error: {message}")] + #[diagnostic(code(zlup::codegen))] + CodegenError { message: String }, + + #[error("file needs formatting: {path}")] + #[diagnostic(code(zlup::fmt::check))] + FormatterCheckFailed { path: String }, + + #[error("lint failed: {path} has {error_count} error(s) and {warning_count} warning(s)")] + #[diagnostic(code(zlup::lint))] + LintFailed { + path: String, + error_count: usize, + warning_count: usize, + }, + + #[error("HUGR codegen requires --features hugr")] + #[diagnostic(code(zlup::feature))] + HugrNotEnabled, + + #[error("config error: {message}")] + #[diagnostic(code(zlup::config))] + ConfigError { message: String }, + + #[error("project directory '{path}' already exists")] + #[diagnostic(code(zlup::init))] + ProjectExists { path: String }, + + #[error("failed to create directory '{path}': {source}")] + #[diagnostic(code(zlup::io::mkdir))] + CreateDirError { + path: String, + #[source] + source: io::Error, + }, +} + +// ============================================================================= +// Input/Output Helpers +// ============================================================================= + +/// Read source from file or stdin. +fn read_source(path: &PathBuf) -> Result<(String, String), CliError> { + if path.as_os_str() == "-" { + let mut source = String::new(); + io::stdin() + .read_to_string(&mut source) + .map_err(|e| CliError::ReadError { + path: "".to_string(), + source: e, + })?; + Ok((source, "".to_string())) + } else { + let source = fs::read_to_string(path).map_err(|e| CliError::ReadError { + path: path.display().to_string(), + source: e, + })?; + Ok((source, path.display().to_string())) + } +} + +/// Compute the byte offset for a source location. +fn location_to_offset(source: &str, location: &zlup::ast::SourceLocation) -> usize { + source + .lines() + .take(location.line.saturating_sub(1) as usize) + .map(|l| l.len() + 1) + .sum::() + + location.column.saturating_sub(1) as usize +} + +/// Convert a pest parse error message to a more user-friendly message. +fn friendly_parse_error(pest_message: &str) -> String { + // Check for common patterns and provide better messages with suggestions + if pest_message.contains("expected identifier") { + "expected an identifier (variable, function, or type name)".to_string() + } else if pest_message.contains("expected type_expr") { + "expected a type (e.g., u32, bool, []u8, ?T)".to_string() + } else if pest_message.contains("expected expr") { + "expected an expression".to_string() + } else if pest_message.contains("expected statement") { + "expected a statement (binding, assignment, if, for, etc.)".to_string() + } else if pest_message.contains("expected \"(\"") { + "expected '(' - check for missing parentheses".to_string() + } else if pest_message.contains("expected \")\"") { + "expected ')' - check for unmatched parentheses".to_string() + } else if pest_message.contains("expected \"{\"") { + "expected '{' - blocks require braces".to_string() + } else if pest_message.contains("expected \"}\"") { + "expected '}' - check for unmatched braces".to_string() + } else if pest_message.contains("expected \"[\"") { + "expected '[' - arrays use square brackets".to_string() + } else if pest_message.contains("expected \"]\"") { + "expected ']' - check for unmatched brackets".to_string() + } else if pest_message.contains("expected \";\"") { + "expected ';' - statements must end with semicolon".to_string() + } else if pest_message.contains("expected \":=\"") || pest_message.contains("expected \"=\"") { + "expected ':=' for binding or '=' for assignment".to_string() + } else if pest_message.contains("expected assign_op") { + "unexpected token - expected assignment (=, +=, -=, etc.)".to_string() + } else if pest_message.contains("expected top_level_decl") { + "expected a declaration (fn, struct, enum, or binding)".to_string() + } else if pest_message.contains("expected return_type") { + "expected '-> T' return type after function parameters".to_string() + } else if pest_message.contains("expected param") { + "expected function parameter (name: Type)".to_string() + } else if pest_message.contains("expected block") { + "expected a block { ... }".to_string() + } else if pest_message.contains("expected EOI") { + "unexpected content after end of file".to_string() + } else if pest_message.contains("expected string_literal") { + "expected a string (\"...\", r\"...\", or \"\"\"...\"\"\")".to_string() + } else if pest_message.contains("expected number_literal") { + "expected a number (42, 0xFF, 3.14, etc.)".to_string() + } else if pest_message.contains("expected bool_literal") { + "expected 'true' or 'false'".to_string() + } else { + // Fall back to the original message but clean it up + pest_message + .lines() + .next() + .unwrap_or(pest_message) + .to_string() + } +} + +/// Write output to file or stdout. +fn write_output(path: Option<&PathBuf>, content: &str) -> Result<(), CliError> { + match path { + Some(p) if p.as_os_str() != "-" => { + fs::write(p, content).map_err(|e| CliError::WriteError { + path: p.display().to_string(), + source: e, + }) + } + _ => io::stdout() + .write_all(content.as_bytes()) + .map_err(|e| CliError::WriteError { + path: "".to_string(), + source: e, + }), + } +} + +/// Derive output path from input path and format. +fn derive_output_path(input: &Path, format: Format) -> PathBuf { + if input.as_os_str() == "-" { + return PathBuf::from("-"); + } + + let stem = input.file_stem().unwrap_or_default(); + let ext = match format { + Format::Slr => "slr.json", + Format::PhirJson => "phir.json", + Format::Qasm => "qasm", + #[cfg(feature = "hugr")] + Format::Hugr => "hugr", + }; + + input.with_file_name(format!("{}.{}", stem.to_string_lossy(), ext)) +} + +// ============================================================================= +// Commands +// ============================================================================= + +/// Execute the init command - create a new project. +fn cmd_init(name: String, here: bool) -> Result<(), CliError> { + let project_dir = if here { + std::env::current_dir().map_err(|e| CliError::ReadError { + path: ".".to_string(), + source: e, + })? + } else { + let dir = PathBuf::from(&name); + if dir.exists() { + return Err(CliError::ProjectExists { + path: dir.display().to_string(), + }); + } + fs::create_dir_all(&dir).map_err(|e| CliError::CreateDirError { + path: dir.display().to_string(), + source: e, + })?; + dir + }; + + // Create zlup.toml + let config = Config::new(&name); + let config_path = project_dir.join(CONFIG_FILE_NAME); + let config_content = config.to_toml().map_err(|e| CliError::ConfigError { + message: e.to_string(), + })?; + fs::write(&config_path, config_content).map_err(|e| CliError::WriteError { + path: config_path.display().to_string(), + source: e, + })?; + + // Create main.zlp with example content + let main_path = project_dir.join("main.zlp"); + let main_content = r#"//! Main entry point for the quantum program. + +/// Main function - program entry point. +pub fn main() -> unit { + // Allocate qubits (no mut needed - just applying gates) + q := qalloc(2); + pz q; + + // Create Bell state + h q[0]; + cx (q[0], q[1]); + + // Measure + result := mz(u1) q[0]; + + return unit; +} +"#; + fs::write(&main_path, main_content).map_err(|e| CliError::WriteError { + path: main_path.display().to_string(), + source: e, + })?; + + eprintln!( + "Created new project '{}' at {}", + name, + project_dir.display() + ); + eprintln!(" {} - project configuration", CONFIG_FILE_NAME); + eprintln!(" main.zlp - main source file"); + eprintln!(); + eprintln!("To build: cd {} && zlup build", project_dir.display()); + + Ok(()) +} + +/// Execute the build command - build using zlup.toml. +fn cmd_build( + strict_override: Option, + target_override: Option, + format_override: Option, + mode: Mode, + compact: bool, +) -> Result<(), CliError> { + // Find zlup.toml + let current_dir = std::env::current_dir().map_err(|e| CliError::ReadError { + path: ".".to_string(), + source: e, + })?; + + let (config, config_path) = + Config::find_and_load(¤t_dir).map_err(|e| CliError::ConfigError { + message: e.to_string(), + })?; + + let project_root = Config::project_root(&config_path); + + // Determine format (CLI overrides config) + let format = format_override.unwrap_or(match config.build.target { + TargetConfig::Slr => Format::Slr, + #[cfg(feature = "hugr")] + TargetConfig::Hugr => Format::Hugr, + #[cfg(not(feature = "hugr"))] + TargetConfig::Hugr => { + eprintln!("Warning: HUGR format specified but not enabled, using SLR"); + Format::Slr + } + }); + + // Target defaults to simulator unless overridden + let target = target_override.unwrap_or(Target::Simulator); + + // Get entry file + let entry_path = config.entry_path(&config_path); + if !entry_path.exists() { + return Err(CliError::ReadError { + path: entry_path.display().to_string(), + source: io::Error::new(io::ErrorKind::NotFound, "entry file not found"), + }); + } + + // Create output directory + let output_dir = config.output_path(&config_path); + if !output_dir.exists() { + fs::create_dir_all(&output_dir).map_err(|e| CliError::CreateDirError { + path: output_dir.display().to_string(), + source: e, + })?; + } + + // Derive output file name + let output_ext = match format { + Format::Slr => "slr.json", + Format::PhirJson => "phir.json", + Format::Qasm => "qasm", + #[cfg(feature = "hugr")] + Format::Hugr => "hugr", + }; + let output_name = entry_path.file_stem().unwrap_or_default().to_string_lossy(); + let output_path = output_dir.join(format!("{}.{}", output_name, output_ext)); + + // Compute effective settings + let (default_strict, _) = effective_settings(target, mode); + let strict = strict_override.unwrap_or(config.build.strict || default_strict); + + eprintln!( + "Building {} ({}) [{:?} -> {:?}, {}]", + config.package.name, + config.package.version, + target, + format, + if strict { "strict" } else { "normal" } + ); + + // Compile + cmd_compile(CompileOptions { + input: entry_path, + output: Some(output_path.clone()), + target, + format, + mode, + compact, + strict_override: Some(strict), + log_level_override: None, + elide_sim: false, + })?; + + eprintln!( + "Built {} -> {}", + project_root.join(&config.package.entry).display(), + output_path.display() + ); + + Ok(()) +} + +/// Options for the compile command. +struct CompileOptions { + input: PathBuf, + output: Option, + target: Target, + format: Format, + mode: Mode, + compact: bool, + strict_override: Option, + log_level_override: Option, + elide_sim: bool, +} + +/// Execute the compile command. +fn cmd_compile(opts: CompileOptions) -> Result<(), CliError> { + let CompileOptions { + input, + output, + target, + format, + mode, + compact, + strict_override, + log_level_override, + elide_sim, + } = opts; + + // Resolve settings from target + mode with overrides + let (default_strict, default_log_level) = effective_settings(target, mode); + let strict = strict_override.unwrap_or(default_strict); + let log_level = log_level_override.or(default_log_level); + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(|e| { + let (span, message) = if let Some(loc) = e.location() { + let start = location_to_offset(&source, loc); + (SourceSpan::from(start..start + 1), e.to_string()) + } else { + (SourceSpan::from(0..1), e.to_string()) + }; + CliError::SemanticError { + message, + src: NamedSource::new(&filename, source.clone()), + span, + } + })?; + + // Derive module name from filename for log namespacing + let module_name = input + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "main".to_string()); + + // Code generation + let output_content = match format { + Format::Slr => { + use zlup::codegen::slr::LogElisionLevel; + + // Create codegen with release settings if we have any log elision + let mut codegen = if log_level.is_some() { + SlrCodegen::new_release() + } else { + SlrCodegen::new() + }; + + // Set module for automatic log namespacing + codegen.set_module(&module_name); + + // Apply log elision level from profile (possibly overridden) + if let Some(level) = log_level { + codegen.set_log_elision(LogElisionLevel(Some(level))); + } + + // Apply sim mode based on target (with optional full elision override) + let sim_mode = if elide_sim { + zlup::codegen::slr::SimMode::Elide + } else { + target.sim_mode() + }; + codegen.set_sim_mode(sim_mode); + + let slr_program = + codegen + .compile(&program) + .map_err(|e: zlup::codegen::slr::SlrError| CliError::CodegenError { + message: e.to_string(), + })?; + + if compact { + codegen.to_json_compact(&slr_program).map_err( + |e: zlup::codegen::slr::SlrError| CliError::CodegenError { + message: e.to_string(), + }, + )? + } else { + codegen + .to_json(&slr_program) + .map_err(|e: zlup::codegen::slr::SlrError| CliError::CodegenError { + message: e.to_string(), + })? + } + } + Format::PhirJson => { + use zlup::codegen::PhirJsonCodegen; + + let mut codegen = PhirJsonCodegen::new(); + let phir_json_program = + codegen + .compile(&program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })?; + + if compact { + codegen + .to_json_compact(&phir_json_program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })? + } else { + codegen + .to_json(&phir_json_program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })? + } + } + Format::Qasm => { + use zlup::codegen::QasmCodegen; + + let mut codegen = QasmCodegen::new(); + codegen + .compile(&program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })? + } + #[cfg(feature = "hugr")] + Format::Hugr => { + use zlup::codegen::HugrCodegen; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen + .compile(&program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })?; + + // Serialize HUGR to text envelope format (compatible with PECOS hugr_engine) + codegen + .to_string(&hugr) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })? + } + }; + + // Write output + let output_path = output.unwrap_or_else(|| derive_output_path(&input, format)); + write_output(Some(&output_path), &output_content)?; + + eprintln!( + "Compiled {} -> {}", + filename, + if output_path.as_os_str() == "-" { + "".to_string() + } else { + output_path.display().to_string() + } + ); + + Ok(()) +} + +/// Execute the check command. +fn cmd_check(input: PathBuf, strict: bool) -> Result<(), CliError> { + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(|e| { + let (span, message) = if let Some(loc) = e.location() { + let start = location_to_offset(&source, loc); + (SourceSpan::from(start..start + 1), e.to_string()) + } else { + (SourceSpan::from(0..1), e.to_string()) + }; + CliError::SemanticError { + message, + src: NamedSource::new(&filename, source.clone()), + span, + } + })?; + + eprintln!("OK: {}", filename); + Ok(()) +} + +/// Execute the parse command. +fn cmd_parse(input: PathBuf, format: AstFormat) -> Result<(), CliError> { + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Output + let output = match format { + AstFormat::Debug => format!("{:#?}", program), + AstFormat::Json => serde_json::to_string_pretty(&program) + .unwrap_or_else(|e| format!("JSON serialization error: {}", e)), + }; + + println!("{}", output); + Ok(()) +} + +/// Execute the format command. +fn cmd_format(input: PathBuf, write: bool, check: bool) -> Result<(), CliError> { + use zlup::formatter::{FormatOptions, format}; + + let (source, filename) = read_source(&input)?; + let options = FormatOptions::default(); + let formatted = format(&source, &options); + + if check { + // Check mode: exit 1 if file needs formatting + if source != formatted { + eprintln!("Would reformat: {}", filename); + return Err(CliError::FormatterCheckFailed { path: filename }); + } + eprintln!("OK: {}", filename); + return Ok(()); + } + + if write { + // Write mode: write back to file + if input.as_os_str() == "-" { + // Can't write back to stdin + return Err(CliError::WriteError { + path: "".to_string(), + source: io::Error::new(io::ErrorKind::InvalidInput, "cannot write to stdin"), + }); + } + if source != formatted { + fs::write(&input, &formatted).map_err(|e| CliError::WriteError { + path: input.display().to_string(), + source: e, + })?; + eprintln!("Formatted: {}", filename); + } else { + eprintln!("Already formatted: {}", filename); + } + } else { + // Default: print to stdout + print!("{}", formatted); + } + + Ok(()) +} + +// Arguments come from CLI parsing - grouping them wouldn't improve readability +#[allow(clippy::too_many_arguments)] +fn cmd_lint( + input: PathBuf, + level: LintLevel, + deny_warnings: bool, + format: LintFormat, + fix: bool, + unsafe_fixes: bool, + show_diff: bool, + statistics_only: bool, +) -> Result<(), CliError> { + use zlup::linter::{FixSafety, LintConfig, Linter, Severity, apply_fixes}; + + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = e.location.line.saturating_sub(1) as usize * 80 + e.location.column as usize; + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Configure linter + let config = match level { + LintLevel::Minimal => LintConfig::minimal(), + LintLevel::Relaxed => LintConfig::relaxed(), + LintLevel::Strict => LintConfig::strict(), + }; + + // Run linter (with source for fix computation) + let diagnostics = Linter::new(config).with_source(&source).lint(&program); + + // Statistics-only mode + if statistics_only { + let errors = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(); + let warnings = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + let hints = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Hint)) + .count(); + let fixable_safe = diagnostics.iter().filter(|d| d.has_safe_fix()).count(); + let fixable_unsafe = diagnostics + .iter() + .filter(|d| { + d.fix + .as_ref() + .is_some_and(|f| f.safety == FixSafety::Unsafe) + }) + .count(); + + println!("File: {}", filename); + println!("Errors: {}", errors); + println!("Warnings: {}", warnings); + println!("Hints: {}", hints); + println!("Total: {}", diagnostics.len()); + println!("Fixable (safe): {}", fixable_safe); + println!("Fixable (unsafe): {}", fixable_unsafe); + + if errors > 0 || (deny_warnings && warnings > 0) { + return Err(CliError::LintFailed { + path: filename, + error_count: errors, + warning_count: warnings, + }); + } + return Ok(()); + } + + // Diff mode - show what would change without applying + if show_diff { + let fix_result = apply_fixes(&source, &diagnostics, unsafe_fixes); + + if fix_result.safe_fixes_applied > 0 || fix_result.unsafe_fixes_applied > 0 { + eprintln!( + "Would apply {} safe fix(es){} to {}", + fix_result.safe_fixes_applied, + if fix_result.unsafe_fixes_applied > 0 { + format!(" and {} unsafe fix(es)", fix_result.unsafe_fixes_applied) + } else { + String::new() + }, + filename + ); + + // Generate unified diff + print_unified_diff(&source, &fix_result.source, &filename); + + if fix_result.fixes_skipped > 0 { + eprintln!( + "Would skip {} fix(es) (conflicts or safety level)", + fix_result.fixes_skipped + ); + } + } else { + eprintln!("No fixes available"); + } + + // Still return error if there are issues + let has_errors = diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Warning)); + if has_errors || (deny_warnings && has_warnings) { + let errors = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(); + let warnings = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + return Err(CliError::LintFailed { + path: filename, + error_count: errors, + warning_count: warnings, + }); + } + return Ok(()); + } + + // Apply fixes if requested + if fix { + let fix_result = apply_fixes(&source, &diagnostics, unsafe_fixes); + + if fix_result.safe_fixes_applied > 0 || fix_result.unsafe_fixes_applied > 0 { + // Write fixed source back to file + if input.as_os_str() == "-" { + // Can't write back to stdin + return Err(CliError::WriteError { + path: "".to_string(), + source: io::Error::new( + io::ErrorKind::InvalidInput, + "cannot apply fixes to stdin", + ), + }); + } + + fs::write(&input, &fix_result.source).map_err(|e| CliError::WriteError { + path: input.display().to_string(), + source: e, + })?; + + eprintln!( + "Applied {} safe fix(es){} to {}", + fix_result.safe_fixes_applied, + if fix_result.unsafe_fixes_applied > 0 { + format!(" and {} unsafe fix(es)", fix_result.unsafe_fixes_applied) + } else { + String::new() + }, + filename + ); + + if fix_result.fixes_skipped > 0 { + eprintln!( + "Skipped {} fix(es) (conflicts or safety level)", + fix_result.fixes_skipped + ); + } + + // Re-lint to show remaining issues + let program = zlup::parse_file(&fix_result.source, &filename).map_err(|e| { + let start = + e.location.line.saturating_sub(1) as usize * 80 + e.location.column as usize; + CliError::ParseError { + src: NamedSource::new(&filename, fix_result.source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + let config = match level { + LintLevel::Minimal => LintConfig::minimal(), + LintLevel::Relaxed => LintConfig::relaxed(), + LintLevel::Strict => LintConfig::strict(), + }; + + let remaining_diagnostics = Linter::new(config) + .with_source(&fix_result.source) + .lint(&program); + + if remaining_diagnostics.is_empty() { + eprintln!("All issues fixed!"); + return Ok(()); + } + + // Output remaining diagnostics + match format { + LintFormat::Pretty => print_diagnostics_pretty(&remaining_diagnostics, &filename), + LintFormat::Json => print_diagnostics_json(&remaining_diagnostics), + LintFormat::Compact => print_diagnostics_compact(&remaining_diagnostics, &filename), + } + + let errors = remaining_diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(); + let warnings = remaining_diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + let hints = remaining_diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Hint)) + .count(); + + eprintln!(); + eprintln!( + "Remaining: {} error(s), {} warning(s), {} hint(s) in {}", + errors, warnings, hints, filename + ); + + let has_errors = remaining_diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = remaining_diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Warning)); + + if has_errors || (deny_warnings && has_warnings) { + return Err(CliError::LintFailed { + path: filename, + error_count: errors, + warning_count: warnings, + }); + } + + return Ok(()); + } else { + eprintln!("No fixes available to apply"); + } + } + + // Check for errors + let has_errors = diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Warning)); + + // Output diagnostics + match format { + LintFormat::Pretty => print_diagnostics_pretty(&diagnostics, &filename), + LintFormat::Json => print_diagnostics_json(&diagnostics), + LintFormat::Compact => print_diagnostics_compact(&diagnostics, &filename), + } + + // Summary + if !diagnostics.is_empty() { + let errors = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(); + let warnings = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + let hints = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Hint)) + .count(); + let fixable_safe = diagnostics.iter().filter(|d| d.has_safe_fix()).count(); + let fixable_total = diagnostics.iter().filter(|d| d.has_fix()).count(); + + eprintln!(); + eprintln!( + "Found {} error(s), {} warning(s), {} hint(s) in {}", + errors, warnings, hints, filename + ); + + if fixable_safe > 0 { + eprintln!( + "{} issue(s) can be fixed automatically (run with --fix)", + fixable_safe + ); + } + if fixable_total > fixable_safe { + eprintln!( + "{} additional issue(s) can be fixed with --fix --unsafe-fixes", + fixable_total - fixable_safe + ); + } + } else { + eprintln!("No issues found in {}", filename); + } + + // Return error if there are errors, or warnings in deny mode + if has_errors || (deny_warnings && has_warnings) { + return Err(CliError::LintFailed { + path: filename, + error_count: diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(), + warning_count: diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(), + }); + } + + Ok(()) +} + +fn print_diagnostics_pretty(diagnostics: &[zlup::linter::LintDiagnostic], filename: &str) { + use zlup::linter::Severity; + + for diag in diagnostics { + let (severity_str, color) = match diag.severity { + Severity::Hint => ("hint", "\x1b[36m"), // Cyan + Severity::Warning => ("warning", "\x1b[33m"), // Yellow + Severity::Error => ("error", "\x1b[31m"), // Red + Severity::Deny => ("deny", "\x1b[91m"), // Bright Red + }; + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + + if let Some(ref loc) = diag.location { + eprintln!( + "{}{}{}:{}{}: {}{}{}: {}", + bold, + filename, + reset, + loc.line, + loc.column, + color, + severity_str, + reset, + diag.message + ); + } else { + eprintln!( + "{}{}{}: {}{}{}: {}", + bold, filename, reset, color, severity_str, reset, diag.message + ); + } + + eprintln!(" [{}]", diag.rule); + + if let Some(ref suggestion) = diag.suggestion { + eprintln!(" \x1b[32mhelp{}: {}", reset, suggestion); + } + eprintln!(); + } +} + +fn print_diagnostics_json(diagnostics: &[zlup::linter::LintDiagnostic]) { + use serde_json::json; + + let json_diags: Vec<_> = diagnostics + .iter() + .map(|d| { + json!({ + "rule": d.rule, + "message": d.message, + "severity": format!("{:?}", d.severity).to_lowercase(), + "location": d.location.as_ref().map(|l| json!({ + "line": l.line, + "column": l.column + })), + "suggestion": d.suggestion + }) + }) + .collect(); + + println!("{}", serde_json::to_string_pretty(&json_diags).unwrap()); +} + +fn print_diagnostics_compact(diagnostics: &[zlup::linter::LintDiagnostic], filename: &str) { + use zlup::linter::Severity; + + for diag in diagnostics { + let severity = match diag.severity { + Severity::Hint => "H", + Severity::Warning => "W", + Severity::Error => "E", + Severity::Deny => "D", + }; + + if let Some(ref loc) = diag.location { + println!( + "{}:{}:{}: {} [{}] {}", + filename, loc.line, loc.column, severity, diag.rule, diag.message + ); + } else { + println!( + "{}: {} [{}] {}", + filename, severity, diag.rule, diag.message + ); + } + } +} + +/// Print a unified diff between original and fixed source code. +fn print_unified_diff(original: &str, fixed: &str, filename: &str) { + let orig_lines: Vec<&str> = original.lines().collect(); + let fixed_lines: Vec<&str> = fixed.lines().collect(); + + // ANSI colors + let red = "\x1b[31m"; + let green = "\x1b[32m"; + let cyan = "\x1b[36m"; + let reset = "\x1b[0m"; + + println!("{}--- a/{}{}", cyan, filename, reset); + println!("{}+++ b/{}{}", cyan, filename, reset); + + // Simple diff: find changed lines + let max_len = orig_lines.len().max(fixed_lines.len()); + let mut i = 0; + + while i < max_len { + // Find a hunk of changes + let hunk_start = i; + let mut orig_hunk = Vec::new(); + let mut fixed_hunk = Vec::new(); + let mut has_changes = false; + + // Collect context before changes (up to 3 lines) + let context_start = hunk_start.saturating_sub(3); + + // Find changes + while i < max_len { + let orig_line = orig_lines.get(i).copied(); + let fixed_line = fixed_lines.get(i).copied(); + + if orig_line != fixed_line { + has_changes = true; + if let Some(line) = orig_line { + orig_hunk.push((i, line)); + } + if let Some(line) = fixed_line { + fixed_hunk.push((i, line)); + } + i += 1; + } else if has_changes { + // Add trailing context + let mut context_count = 0; + while i < max_len && context_count < 3 { + let ol = orig_lines.get(i).copied(); + let fl = fixed_lines.get(i).copied(); + if ol == fl { + context_count += 1; + i += 1; + } else { + break; + } + } + break; + } else { + i += 1; + } + } + + if has_changes { + // Print hunk header + let orig_start = orig_hunk + .first() + .map(|(n, _)| *n + 1) + .unwrap_or(hunk_start + 1); + let fixed_start = fixed_hunk + .first() + .map(|(n, _)| *n + 1) + .unwrap_or(hunk_start + 1); + println!( + "{}@@ -{},{} +{},{} @@{}", + cyan, + orig_start, + orig_hunk.len(), + fixed_start, + fixed_hunk.len(), + reset + ); + + // Print context before + for j in context_start..hunk_start { + if let Some(line) = orig_lines.get(j) { + println!(" {}", line); + } + } + + // Print removed lines + for (_, line) in &orig_hunk { + println!("{}-{}{}", red, line, reset); + } + + // Print added lines + for (_, line) in &fixed_hunk { + println!("{}+{}{}", green, line, reset); + } + } + } +} + +// ============================================================================= +// Analyze Command +// ============================================================================= + +fn cmd_analyze(input: PathBuf, format: AnalyzeFormat, verbose: bool) -> Result<(), CliError> { + use zlup::analysis::{ + AllocatorAnalysis, DependencyGraph, OperationTagger, analyze_parallelism, + }; + + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Run analysis passes + let allocator_analysis = AllocatorAnalysis::analyze(&program); + let tagger = OperationTagger::tag(&program); + let dep_graph = DependencyGraph::build(tagger.operations); + let summaries = analyze_parallelism(&program); + + match format { + AnalyzeFormat::Text => { + println!("=== Parallelism Analysis: {} ===\n", filename); + + // Allocator summary + println!("Allocators:"); + if allocator_analysis.allocators.is_empty() { + println!(" (none)"); + } else { + for (name, info) in &allocator_analysis.allocators { + let size_str = info + .size + .map(|s| format!("[{}]", s)) + .unwrap_or_else(|| "[?]".to_string()); + println!( + " {} {}qubit (scope depth: {}, line: {})", + name, size_str, info.scope_depth, info.defined_at_line + ); + } + } + println!(); + + // Function summaries + println!("Function Analysis:"); + for summary in &summaries { + println!(" {}:", summary.function_name); + println!(" Total operations: {}", summary.total_ops); + println!(" Quantum operations: {}", summary.quantum_ops); + println!(" Classical operations: {}", summary.classical_ops); + println!(" Parallel layers: {}", summary.num_layers); + println!(" Max parallelism: {} ops/layer", summary.max_parallelism); + println!(); + } + + // Detailed output if verbose + if verbose { + println!("=== Dependency Graph ===\n"); + dep_graph.debug_print(); + } + } + AnalyzeFormat::Json => { + use serde_json::json; + + let allocators: Vec<_> = allocator_analysis + .allocators + .iter() + .map(|(name, info)| { + json!({ + "name": name, + "size": info.size, + "scope_depth": info.scope_depth, + "defined_at_line": info.defined_at_line, + }) + }) + .collect(); + + let functions: Vec<_> = summaries + .iter() + .map(|s| { + json!({ + "name": s.function_name, + "total_ops": s.total_ops, + "quantum_ops": s.quantum_ops, + "classical_ops": s.classical_ops, + "num_layers": s.num_layers, + "max_parallelism": s.max_parallelism, + }) + }) + .collect(); + + let layers = dep_graph.parallel_layers(); + let layer_details: Vec<_> = layers + .iter() + .enumerate() + .map(|(i, ops)| { + let op_details: Vec<_> = ops + .iter() + .map(|&id| { + let op = &dep_graph.operations[id]; + json!({ + "id": op.id, + "description": op.description, + "line": op.line, + "is_quantum": op.touches_qubits(), + }) + }) + .collect(); + json!({ + "layer": i, + "operations": op_details, + }) + }) + .collect(); + + let output = json!({ + "file": filename, + "allocators": allocators, + "functions": functions, + "parallel_layers": layer_details, + "total_operations": dep_graph.operations.len(), + "total_dependencies": dep_graph.edges.len(), + }); + + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } + } + + Ok(()) +} + +// ============================================================================= +// Eval Command +// ============================================================================= + +fn cmd_eval(expr: String, verbose: bool) -> Result<(), CliError> { + use zlup::comptime::ComptimeEvaluator; + + // Read expression from stdin if - + let input = if expr == "-" { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::ReadError { + path: "".to_string(), + source: e, + })?; + buf + } else { + expr + }; + + let input = input.trim(); + + // Wrap in a minimal program context for parsing + // Try as expression first, then as statements + let source = if input.contains(';') || input.contains(":=") || input.starts_with("fn ") { + // Looks like statements - wrap in a function + format!("fn __eval__() -> type {{ {} }}", input) + } else { + // Simple expression - wrap to evaluate + format!("__result__ := {};", input) + }; + + if verbose { + eprintln!("--- Source ---"); + eprintln!("{}", source); + } + + // Parse + let program = zlup::parse(&source).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new("", source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + if verbose { + eprintln!("--- AST ---"); + eprintln!("{:#?}", program); + } + + // Try comptime evaluation + let mut evaluator = ComptimeEvaluator::new(); + + // Evaluate declarations + for decl in &program.declarations { + match decl { + zlup::ast::TopLevelDecl::Binding(binding) => { + if let Some(ref value) = binding.value { + match evaluator.eval_expr(value) { + Ok(value) => { + if binding.name == "__result__" { + // This is our wrapped expression result + println!("{}", value); + } else if verbose { + println!("{} = {}", binding.name, value); + } + } + Err(e) => { + if verbose { + eprintln!("Comptime eval error: {}", e); + } + // Fall back to showing the expression was parsed + println!("(parsed: {})", binding.name); + } + } + } + } + zlup::ast::TopLevelDecl::Fn(func) if verbose => { + println!("fn {} defined", func.name); + } + _ => {} + } + } + + Ok(()) +} + +// ============================================================================= +// Doc Command +// ============================================================================= + +fn cmd_doc(input: PathBuf, output: Option, all: bool) -> Result<(), CliError> { + use zlup::docgen::{DocConfig, extract_doc_items, generate_markdown}; + + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + let config = DocConfig { + include_private: all, + ..Default::default() + }; + + let items = extract_doc_items(&program, &config); + let module_name = input + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "module".to_string()); + let markdown = generate_markdown(&items, &module_name); + + write_output(output.as_ref(), &markdown)?; + Ok(()) +} + +// ============================================================================= +// Test Command +// ============================================================================= + +fn cmd_test( + input: PathBuf, + filter: Option, + strict: bool, + verbose: bool, +) -> Result<(), CliError> { + use zlup::test_runner::{TestOutcome, TestRunConfig, TestRunner, format_results}; + + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + let config = TestRunConfig { + filter, + strict, + verbose, + }; + + let runner = TestRunner::new(config); + let results = runner.run(&program); + let output = format_results(&results); + print!("{}", output); + + // Exit with failure if any tests failed + let has_failures = results + .iter() + .any(|r| matches!(r.outcome, TestOutcome::Fail(_))); + if has_failures { + return Err(CliError::CodegenError { + message: "some tests failed".to_string(), + }); + } + + Ok(()) +} + +// ============================================================================= +// Main +// ============================================================================= + +fn main() -> ExitCode { + // Initialize logging from ZLUP_LOG environment variable + zlup::logging::init(); + + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Init { name, here } => cmd_init(name, here), + + Commands::Build { + strict, + target, + format, + mode, + compact, + } => cmd_build(strict, target, format, mode, compact), + + Commands::Compile { + input, + output, + target, + format, + mode, + compact, + strict, + log_level, + elide_sim, + } => cmd_compile(CompileOptions { + input, + output, + target, + format, + mode, + compact, + strict_override: strict, + log_level_override: log_level, + elide_sim, + }), + + Commands::Check { input, strict } => cmd_check(input, strict), + + Commands::Parse { input, format } => cmd_parse(input, format), + + Commands::Format { + input, + write, + check, + } => cmd_format(input, write, check), + + Commands::Lint { + input, + level, + deny_warnings, + format, + fix, + unsafe_fixes, + diff, + statistics, + } => cmd_lint( + input, + level, + deny_warnings, + format, + fix, + unsafe_fixes, + diff, + statistics, + ), + + Commands::Eval { expr, verbose } => cmd_eval(expr, verbose), + + Commands::Analyze { + input, + format, + verbose, + } => cmd_analyze(input, format, verbose), + + Commands::Doc { input, output, all } => cmd_doc(input, output, all), + + Commands::Test { + input, + filter, + strict, + verbose, + } => cmd_test(input, filter, strict, verbose), + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{:?}", miette::Report::new(e)); + ExitCode::FAILURE + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // ------------------------------------------------------------------------- + // Target defaults + // ------------------------------------------------------------------------- + + #[test] + fn target_simulator_is_permissive() { + assert!(!Target::Simulator.default_strict()); + assert_eq!(Target::Simulator.default_log_elision(), None); + } + + #[test] + fn target_hardware_is_strict() { + assert!(Target::Hardware.default_strict()); + assert_eq!(Target::Hardware.default_log_elision(), Some(300)); // Warn+ + } + + #[test] + fn target_emulator_is_strict_but_more_logs() { + assert!(Target::Emulator.default_strict()); + assert_eq!(Target::Emulator.default_log_elision(), Some(200)); // Info+ + } + + // ------------------------------------------------------------------------- + // Mode behavior + // ------------------------------------------------------------------------- + + #[test] + fn mode_debug_is_permissive() { + assert!(!Mode::Debug.strict_bias()); + assert_eq!(Mode::Debug.log_elision_adjustment(), None); + } + + #[test] + fn mode_release_adds_strictness() { + assert!(Mode::Release.strict_bias()); + assert_eq!(Mode::Release.log_elision_adjustment(), Some(100)); // Debug+ + } + + // ------------------------------------------------------------------------- + // Effective settings (target + mode combinations) + // ------------------------------------------------------------------------- + + #[test] + fn simulator_debug_is_fully_permissive() { + let (strict, log_elision) = effective_settings(Target::Simulator, Mode::Debug); + assert!(!strict); + assert_eq!(log_elision, None); // All logs + } + + #[test] + fn simulator_release_enables_strict_and_elides_trace() { + let (strict, log_elision) = effective_settings(Target::Simulator, Mode::Release); + assert!(strict); // Release adds strict + assert_eq!(log_elision, Some(100)); // Debug+ (elide trace) + } + + #[test] + fn hardware_debug_is_strict_but_keeps_warn_logs() { + let (strict, log_elision) = effective_settings(Target::Hardware, Mode::Debug); + assert!(strict); // Hardware is always strict + assert_eq!(log_elision, Some(300)); // Warn+ from target + } + + #[test] + fn hardware_release_is_strict_with_warn_logs() { + let (strict, log_elision) = effective_settings(Target::Hardware, Mode::Release); + assert!(strict); + // Hardware default (300) > Release adjustment (100), so 300 wins + assert_eq!(log_elision, Some(300)); + } + + #[test] + fn emulator_debug_is_strict_with_info_logs() { + let (strict, log_elision) = effective_settings(Target::Emulator, Mode::Debug); + assert!(strict); + assert_eq!(log_elision, Some(200)); // Info+ + } + + #[test] + fn emulator_release_is_strict_with_info_logs() { + let (strict, log_elision) = effective_settings(Target::Emulator, Mode::Release); + assert!(strict); + // Emulator default (200) > Release adjustment (100), so 200 wins + assert_eq!(log_elision, Some(200)); + } + + // ------------------------------------------------------------------------- + // Output path derivation + // ------------------------------------------------------------------------- + + #[test] + fn derive_output_path_slr() { + let input = Path::new("/path/to/program.zlp"); + let output = derive_output_path(input, Format::Slr); + assert_eq!(output, Path::new("/path/to/program.slr.json")); + } + + #[test] + fn derive_output_path_phir_json() { + let input = Path::new("/path/to/program.zlp"); + let output = derive_output_path(input, Format::PhirJson); + assert_eq!(output, Path::new("/path/to/program.phir.json")); + } + + #[test] + fn derive_output_path_qasm() { + let input = Path::new("/path/to/program.zlp"); + let output = derive_output_path(input, Format::Qasm); + assert_eq!(output, Path::new("/path/to/program.qasm")); + } + + #[test] + fn derive_output_path_stdin_returns_stdout() { + let input = Path::new("-"); + let output = derive_output_path(input, Format::Slr); + assert_eq!(output, Path::new("-")); + } + + // ------------------------------------------------------------------------- + // Log level constants (for reference in tests) + // ------------------------------------------------------------------------- + + #[test] + fn log_levels_are_spaced_by_100() { + // Trace = 0, Debug = 100, Info = 200, Warn = 300, Error = 400 + // This documents the expected spacing + assert_eq!(Target::Emulator.default_log_elision(), Some(200)); // Info + assert_eq!(Target::Hardware.default_log_elision(), Some(300)); // Warn + assert_eq!(Mode::Release.log_elision_adjustment(), Some(100)); // Debug + } +} diff --git a/exp/zlup/src/module.rs b/exp/zlup/src/module.rs new file mode 100644 index 000000000..180b95134 --- /dev/null +++ b/exp/zlup/src/module.rs @@ -0,0 +1,687 @@ +//! Module system for Zlup. +//! +//! This module provides support for importing and organizing code across multiple files. +//! +//! ## Usage +//! +//! ```zlup +//! // Import a local file +//! utils := @import("utils.zlp"); +//! +//! // Import from a subdirectory +//! qec := @import("lib/qec.zlp"); +//! +//! // Access exported symbols +//! x := utils.helper_function(); +//! ``` +//! +//! ## Module Resolution +//! +//! Import paths are resolved relative to the importing file's directory. +//! - `"foo.zlp"` -> same directory +//! - `"lib/foo.zlp"` -> lib subdirectory +//! - `"../foo.zlp"` -> parent directory + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +use crate::ast::{Program, TopLevelDecl, TypeExpr}; +use crate::parser::ParseError; + +// ============================================================================= +// Errors +// ============================================================================= + +/// Module loading errors. +#[derive(Debug, Error)] +pub enum ModuleError { + #[error("module not found: {path}")] + NotFound { path: String }, + + #[error("failed to read module '{path}': {source}")] + ReadError { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse module '{path}': {message}")] + ParseError { path: String, message: String }, + + #[error("circular import detected: {path}")] + CircularImport { path: String }, + + #[error("invalid import path: {path}")] + InvalidPath { path: String }, +} + +impl From for ModuleError { + fn from(err: ParseError) -> Self { + ModuleError::ParseError { + path: err.location.file.unwrap_or_default(), + message: err.message, + } + } +} + +/// Result type for module operations. +pub type ModuleResult = Result; + +// ============================================================================= +// Module +// ============================================================================= + +/// A loaded module. +#[derive(Debug, Clone)] +pub struct Module { + /// Absolute path to the module file. + pub path: PathBuf, + /// The module's AST. + pub program: Program, + /// Exported symbols (pub declarations). + pub exports: BTreeMap, +} + +/// An exported symbol from a module. +#[derive(Debug, Clone)] +pub enum ExportedSymbol { + /// A function declaration with signature. + Function { + name: String, + params: Vec<(String, TypeExpr)>, + return_type: Option, + }, + /// A constant declaration. + Const { name: String }, + /// A type declaration (struct, enum, union). + Type { name: String }, + /// An error set declaration (classical errors). + ErrorSet { + name: String, + /// The error variant names in this set. + variants: Vec, + }, + /// A fault set declaration (quantum faults). + FaultSet { + name: String, + /// The fault variant names in this set. + variants: Vec, + }, +} + +impl Module { + /// Create a new module from an AST. + pub fn new(path: PathBuf, program: Program) -> Self { + let exports = Self::collect_exports(&program); + Self { + path, + program, + exports, + } + } + + /// Collect exported symbols from the program. + fn collect_exports(program: &Program) -> BTreeMap { + let mut exports = BTreeMap::new(); + + for decl in &program.declarations { + match decl { + TopLevelDecl::Fn(fn_decl) if fn_decl.is_pub => { + let params: Vec<(String, TypeExpr)> = fn_decl + .params + .iter() + .map(|p| (p.name.clone(), p.ty.clone())) + .collect(); + exports.insert( + fn_decl.name.clone(), + ExportedSymbol::Function { + name: fn_decl.name.clone(), + params, + return_type: fn_decl.return_type.clone(), + }, + ); + } + TopLevelDecl::Binding(binding) if binding.is_pub => { + exports.insert( + binding.name.clone(), + ExportedSymbol::Const { + name: binding.name.clone(), + }, + ); + } + TopLevelDecl::Struct(struct_decl) if struct_decl.is_pub => { + exports.insert( + struct_decl.name.clone(), + ExportedSymbol::Type { + name: struct_decl.name.clone(), + }, + ); + } + TopLevelDecl::Enum(enum_decl) if enum_decl.is_pub => { + exports.insert( + enum_decl.name.clone(), + ExportedSymbol::Type { + name: enum_decl.name.clone(), + }, + ); + } + TopLevelDecl::Union(union_decl) if union_decl.is_pub => { + exports.insert( + union_decl.name.clone(), + ExportedSymbol::Type { + name: union_decl.name.clone(), + }, + ); + } + TopLevelDecl::ErrorSet(error_set) if error_set.is_pub => { + exports.insert( + error_set.name.clone(), + ExportedSymbol::ErrorSet { + name: error_set.name.clone(), + variants: error_set.variants.iter().map(|v| v.name.clone()).collect(), + }, + ); + } + TopLevelDecl::FaultSet(fault_set) if fault_set.is_pub => { + exports.insert( + fault_set.name.clone(), + ExportedSymbol::FaultSet { + name: fault_set.name.clone(), + variants: fault_set.variants.iter().map(|v| v.name.clone()).collect(), + }, + ); + } + _ => {} + } + } + + exports + } + + /// Check if a symbol is exported. + pub fn has_export(&self, name: &str) -> bool { + self.exports.contains_key(name) + } + + /// Get an exported symbol. + pub fn get_export(&self, name: &str) -> Option<&ExportedSymbol> { + self.exports.get(name) + } +} + +// ============================================================================= +// Module Loader +// ============================================================================= + +/// Loads and caches modules. +#[derive(Debug, Default)] +pub struct ModuleLoader { + /// Cached modules by absolute path. + cache: BTreeMap, + /// Currently loading modules (for circular import detection). + loading: Vec, + /// Search paths for modules. + search_paths: Vec, +} + +impl ModuleLoader { + /// Create a new module loader. + pub fn new() -> Self { + Self { + cache: BTreeMap::new(), + loading: Vec::new(), + search_paths: Vec::new(), + } + } + + /// Add a search path for modules. + pub fn add_search_path(&mut self, path: impl AsRef) { + self.search_paths.push(path.as_ref().to_path_buf()); + } + + /// Load a module from an import path. + /// + /// # Arguments + /// * `import_path` - The path from the @import directive + /// * `from_file` - The file containing the import (for relative resolution) + pub fn load(&mut self, import_path: &str, from_file: Option<&Path>) -> ModuleResult<&Module> { + // Resolve the import path + let resolved_path = self.resolve_path(import_path, from_file)?; + + // Check cache + if self.cache.contains_key(&resolved_path) { + return Ok(self.cache.get(&resolved_path).unwrap()); + } + + // Check for circular imports + if self.loading.contains(&resolved_path) { + return Err(ModuleError::CircularImport { + path: resolved_path.display().to_string(), + }); + } + + // Mark as loading + self.loading.push(resolved_path.clone()); + + // Load and parse the file + let source = fs::read_to_string(&resolved_path).map_err(|e| ModuleError::ReadError { + path: resolved_path.display().to_string(), + source: e, + })?; + + let filename = resolved_path.display().to_string(); + let program = crate::parse_file(&source, &filename)?; + + // Create module + let module = Module::new(resolved_path.clone(), program); + + // Remove from loading + self.loading.retain(|p| p != &resolved_path); + + // Cache and return + self.cache.insert(resolved_path.clone(), module); + Ok(self.cache.get(&resolved_path).unwrap()) + } + + /// Resolve an import path to an absolute path. + /// + /// Resolution follows Zig-style semantics: + /// - `@import("foo.zlp")` - looks for `foo.zlp` directly + /// - `@import("foo")` - looks for `foo.zlp` OR `foo/foo.zlp` (directory with entry file) + /// - `@import("std")` - special case for standard library + fn resolve_path(&self, import_path: &str, from_file: Option<&Path>) -> ModuleResult { + // Validate import path + if import_path.is_empty() { + return Err(ModuleError::InvalidPath { + path: import_path.to_string(), + }); + } + + // Handle special imports + if import_path == "std" { + return self.resolve_std(); + } + + // Get the directory of the importing file + let base_dir = if let Some(from) = from_file { + from.parent().unwrap_or(Path::new(".")).to_path_buf() + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + + // Try to find the module + self.find_module(import_path, &base_dir) + } + + /// Resolve the standard library path. + fn resolve_std(&self) -> ModuleResult { + // Standard library location resolution: + // 1. Check ZLUP_STDLIB_PATH environment variable + // 2. Check for lib/std relative to executable + // 3. Check search paths + + // Try environment variable first + if let Ok(stdlib_path) = std::env::var("ZLUP_STDLIB_PATH") { + let stdlib = PathBuf::from(&stdlib_path); + + // Try std/std.zlp (directory with entry file) + let std_dir_entry = stdlib.join("std").join("std.zlp"); + if std_dir_entry.exists() { + return std_dir_entry + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); + } + + // Try std.zlp directly + let std_file = stdlib.join("std.zlp"); + if std_file.exists() { + return std_file.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_file.display().to_string(), + source: e, + }); + } + } + + // Try relative to executable + if let Ok(exe_path) = std::env::current_exe() + && let Some(exe_dir) = exe_path.parent() + { + let lib_dir = exe_dir.join("lib"); + + // Try lib/std/std.zlp + let std_dir_entry = lib_dir.join("std").join("std.zlp"); + if std_dir_entry.exists() { + return std_dir_entry + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); + } + + // Try lib/std.zlp + let std_file = lib_dir.join("std.zlp"); + if std_file.exists() { + return std_file.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_file.display().to_string(), + source: e, + }); + } + } + + // Try search paths + for search_path in &self.search_paths { + // Try std/std.zlp + let std_dir_entry = search_path.join("std").join("std.zlp"); + if std_dir_entry.exists() { + return std_dir_entry + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); + } + + // Try std.zlp + let std_file = search_path.join("std.zlp"); + if std_file.exists() { + return std_file.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_file.display().to_string(), + source: e, + }); + } + } + + Err(ModuleError::NotFound { + path: "std (set ZLUP_STDLIB_PATH to point to stdlib directory)".to_string(), + }) + } + + /// Find a module given an import path and base directory. + /// + /// Tries multiple resolution strategies: + /// 1. Direct path (if it has .zlp extension) + /// 2. Path with .zlp appended + /// 3. Directory with entry file (name/name.zlp) + fn find_module(&self, import_path: &str, base_dir: &Path) -> ModuleResult { + let has_extension = import_path.ends_with(".zlp"); + + // Build list of candidates to try + let mut candidates = Vec::new(); + + // If it already has .zlp extension, try as-is first + if has_extension { + candidates.push(base_dir.join(import_path)); + } else { + // Try with .zlp extension + candidates.push(base_dir.join(format!("{}.zlp", import_path))); + + // Try as directory with entry file (Zig-style: foo -> foo/foo.zlp) + let module_name = Path::new(import_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(import_path); + candidates.push( + base_dir + .join(import_path) + .join(format!("{}.zlp", module_name)), + ); + } + + // Try candidates relative to base_dir + for candidate in &candidates { + if candidate.exists() { + return candidate + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: candidate.display().to_string(), + source: e, + }); + } + } + + // Try search paths with same candidate patterns + for search_path in &self.search_paths { + let search_candidates: Vec = if has_extension { + vec![search_path.join(import_path)] + } else { + let module_name = Path::new(import_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(import_path); + vec![ + search_path.join(format!("{}.zlp", import_path)), + search_path + .join(import_path) + .join(format!("{}.zlp", module_name)), + ] + }; + + for candidate in search_candidates { + if candidate.exists() { + return candidate + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: candidate.display().to_string(), + source: e, + }); + } + } + } + + Err(ModuleError::NotFound { + path: import_path.to_string(), + }) + } + + /// Get a cached module. + pub fn get(&self, path: &Path) -> Option<&Module> { + self.cache.get(path) + } + + /// Get all loaded modules. + pub fn modules(&self) -> impl Iterator { + self.cache.values() + } + + /// Clear the module cache. + pub fn clear_cache(&mut self) { + self.cache.clear(); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + #[test] + fn test_module_exports() { + let source = r#" + pub fn helper() -> unit {} + fn private_fn() -> unit {} + pub VALUE: u32 = 42; + PRIVATE: u32 = 0; + "#; + + let program = crate::parse_file(source, "test.zlp").unwrap(); + let module = Module::new(PathBuf::from("test.zlp"), program); + + assert!(module.has_export("helper")); + assert!(module.has_export("VALUE")); + assert!(!module.has_export("private_fn")); + assert!(!module.has_export("PRIVATE")); + } + + #[test] + fn test_module_loader_basic() { + let temp_dir = TempDir::new().unwrap(); + + // Create a module file + let module_path = temp_dir.path().join("utils.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn helper() -> unit {{}}").unwrap(); + + // Create a main file that imports it + let main_path = temp_dir.path().join("main.zlp"); + let mut file = fs::File::create(&main_path).unwrap(); + writeln!(file, "utils := @import(\"utils.zlp\");").unwrap(); + writeln!(file, "fn main() -> unit {{}}").unwrap(); + + // Load the module + let mut loader = ModuleLoader::new(); + let module = loader.load("utils.zlp", Some(&main_path)).unwrap(); + + assert!(module.has_export("helper")); + } + + #[test] + fn test_module_loader_not_found() { + let mut loader = ModuleLoader::new(); + let result = loader.load("nonexistent.zlp", None); + assert!(matches!(result, Err(ModuleError::NotFound { .. }))); + } + + #[test] + fn test_circular_import_detection() { + let temp_dir = TempDir::new().unwrap(); + + // Create two files that import each other + let a_path = temp_dir.path().join("a.zlp"); + let b_path = temp_dir.path().join("b.zlp"); + + let mut file = fs::File::create(&a_path).unwrap(); + writeln!(file, "b := @import(\"b.zlp\");").unwrap(); + writeln!(file, "pub fn from_a() -> unit {{}}").unwrap(); + + let mut file = fs::File::create(&b_path).unwrap(); + writeln!(file, "a := @import(\"a.zlp\");").unwrap(); + writeln!(file, "pub fn from_b() -> unit {{}}").unwrap(); + + // The loader itself doesn't detect cycles during parsing + // (that would require semantic analysis to process @import) + // But we can test the loading mechanism + let mut loader = ModuleLoader::new(); + + // Loading a.zlp should work (doesn't process imports during parse) + let result = loader.load("a.zlp", Some(&temp_dir.path().join("main.zlp"))); + assert!(result.is_ok()); + } + + #[test] + fn test_search_paths() { + let temp_dir = TempDir::new().unwrap(); + let lib_dir = temp_dir.path().join("lib"); + fs::create_dir(&lib_dir).unwrap(); + + // Create a module in lib/ + let module_path = lib_dir.join("mymod.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn mymod_fn() -> unit {{}}").unwrap(); + + // Load with search path + let mut loader = ModuleLoader::new(); + loader.add_search_path(&lib_dir); + + let module = loader.load("mymod.zlp", None).unwrap(); + assert!(module.has_export("mymod_fn")); + } + + #[test] + fn test_no_extension_import() { + // @import("utils") should find utils.zlp + let temp_dir = TempDir::new().unwrap(); + + let module_path = temp_dir.path().join("utils.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn util_fn() -> unit {{}}").unwrap(); + + let main_path = temp_dir.path().join("main.zlp"); + + let mut loader = ModuleLoader::new(); + let module = loader.load("utils", Some(&main_path)).unwrap(); + + assert!(module.has_export("util_fn")); + } + + #[test] + fn test_directory_style_import() { + // @import("mylib") should find mylib/mylib.zlp (Zig-style) + let temp_dir = TempDir::new().unwrap(); + + let lib_dir = temp_dir.path().join("mylib"); + fs::create_dir(&lib_dir).unwrap(); + + let module_path = lib_dir.join("mylib.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn lib_fn() -> unit {{}}").unwrap(); + + let main_path = temp_dir.path().join("main.zlp"); + + let mut loader = ModuleLoader::new(); + let module = loader.load("mylib", Some(&main_path)).unwrap(); + + assert!(module.has_export("lib_fn")); + } + + #[test] + fn test_nested_directory_import() { + // @import("qec/decoder") should find qec/decoder.zlp + let temp_dir = TempDir::new().unwrap(); + + let qec_dir = temp_dir.path().join("qec"); + fs::create_dir(&qec_dir).unwrap(); + + let module_path = qec_dir.join("decoder.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn decode() -> unit {{}}").unwrap(); + + let main_path = temp_dir.path().join("main.zlp"); + + let mut loader = ModuleLoader::new(); + let module = loader.load("qec/decoder", Some(&main_path)).unwrap(); + + assert!(module.has_export("decode")); + } + + #[test] + fn test_explicit_extension_preferred() { + // @import("utils.zlp") should find utils.zlp even if utils/utils.zlp exists + let temp_dir = TempDir::new().unwrap(); + + // Create utils.zlp + let direct_path = temp_dir.path().join("utils.zlp"); + let mut file = fs::File::create(&direct_path).unwrap(); + writeln!(file, "pub fn direct() -> unit {{}}").unwrap(); + + // Create utils/utils.zlp + let utils_dir = temp_dir.path().join("utils"); + fs::create_dir(&utils_dir).unwrap(); + let dir_path = utils_dir.join("utils.zlp"); + let mut file = fs::File::create(&dir_path).unwrap(); + writeln!(file, "pub fn from_dir() -> unit {{}}").unwrap(); + + let main_path = temp_dir.path().join("main.zlp"); + + let mut loader = ModuleLoader::new(); + + // Explicit extension should find the direct file + let module = loader.load("utils.zlp", Some(&main_path)).unwrap(); + assert!(module.has_export("direct")); + + // Without extension, should find direct file first (before dir) + loader.clear_cache(); + let module = loader.load("utils", Some(&main_path)).unwrap(); + assert!(module.has_export("direct")); + } +} diff --git a/exp/zlup/src/optimize.rs b/exp/zlup/src/optimize.rs new file mode 100644 index 000000000..e61486a4c --- /dev/null +++ b/exp/zlup/src/optimize.rs @@ -0,0 +1,2868 @@ +//! Optimization passes for Zlup AST. +//! +//! This module provides various optimization passes that transform the AST +//! to produce more efficient code. All optimizations preserve program semantics. +//! +//! ## Available Passes +//! +//! - **Constant Folding**: Evaluate constant expressions at compile time +//! - **Dead Code Elimination**: Remove unreachable code +//! - **Unused Binding Elimination**: Remove unused variable declarations +//! - **Gate Cancellation**: Cancel adjacent inverse quantum gates (X X = I, H H = I) +//! - **Identity Removal**: Remove gates that have no effect +//! +//! ## Usage +//! +//! ```rust +//! use zlup::optimize::Optimizer; +//! +//! let source = "fn main() -> unit { x := 1 + 2; return unit; }"; +//! let program = zlup::parse(source).expect("parse failed"); +//! let mut optimizer = Optimizer::new(); +//! let optimized = optimizer.optimize(program); +//! // optimized has constant expressions folded (1 + 2 -> 3) +//! ``` + +use std::collections::BTreeSet; + +use crate::ast::{ + Attribute, BinaryExpr, BinaryOp, Binding, Block, BoolLit, ElseBranch, Expr, FloatLit, ForRange, + ForStmt, GateKind, GateOp, IfStmt, IntLit, Program, SlotRef, Stmt, TopLevelDecl, UnaryExpr, + UnaryOp, +}; +use crate::comptime::{ComptimeEvaluator, ComptimeValue}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/// Configuration for the optimizer. +#[derive(Debug, Clone)] +pub struct OptimizeConfig { + /// Enable constant folding + pub constant_folding: bool, + /// Enable dead code elimination + pub dead_code_elimination: bool, + /// Enable unused binding elimination + pub unused_binding_elimination: bool, + /// Enable gate cancellation + pub gate_cancellation: bool, + /// Enable identity gate removal + pub identity_removal: bool, + /// Enable inline for loop unrolling + pub inline_for_unrolling: bool, + /// Maximum number of optimization iterations + pub max_iterations: usize, + /// Maximum number of iterations for inline for unrolling (safety limit) + pub max_inline_for_iterations: usize, +} + +impl Default for OptimizeConfig { + fn default() -> Self { + Self { + constant_folding: true, + dead_code_elimination: true, + unused_binding_elimination: true, + gate_cancellation: true, + identity_removal: true, + inline_for_unrolling: true, + max_iterations: 10, + max_inline_for_iterations: 1024, + } + } +} + +impl OptimizeConfig { + /// Create a config with all optimizations enabled. + pub fn all() -> Self { + Self::default() + } + + /// Create a config with no optimizations. + pub fn none() -> Self { + Self { + constant_folding: false, + dead_code_elimination: false, + unused_binding_elimination: false, + gate_cancellation: false, + identity_removal: false, + inline_for_unrolling: false, + max_iterations: 0, + max_inline_for_iterations: 0, + } + } +} + +// ============================================================================= +// Statistics +// ============================================================================= + +/// Statistics about optimizations performed. +#[derive(Debug, Clone, Default)] +pub struct OptimizeStats { + /// Number of constants folded + pub constants_folded: usize, + /// Number of dead code blocks removed + pub dead_code_removed: usize, + /// Number of unused bindings removed + pub unused_bindings_removed: usize, + /// Number of gate pairs cancelled + pub gates_cancelled: usize, + /// Number of identity gates removed + pub identities_removed: usize, + /// Number of inline for loops unrolled + pub inline_for_unrolled: usize, + /// Total number of statements generated from inline for unrolling + pub inline_for_statements_generated: usize, + /// Number of optimization iterations performed + pub iterations: usize, +} + +// ============================================================================= +// Optimizer +// ============================================================================= + +/// AST optimizer. +pub struct Optimizer { + config: OptimizeConfig, + stats: OptimizeStats, + evaluator: ComptimeEvaluator, +} + +impl Default for Optimizer { + fn default() -> Self { + Self::new() + } +} + +impl Optimizer { + /// Create a new optimizer with default configuration. + pub fn new() -> Self { + Self { + config: OptimizeConfig::default(), + evaluator: ComptimeEvaluator::new(), + stats: OptimizeStats::default(), + } + } + + /// Create a new optimizer with specific configuration. + pub fn with_config(config: OptimizeConfig) -> Self { + Self { + config, + evaluator: ComptimeEvaluator::new(), + stats: OptimizeStats::default(), + } + } + + /// Get optimization statistics. + pub fn stats(&self) -> &OptimizeStats { + &self.stats + } + + /// Optimize a program. + pub fn optimize(&mut self, program: Program) -> Program { + let mut result = program; + + for iteration in 0..self.config.max_iterations { + let before_stats = self.stats.clone(); + + result = self.optimize_pass(result); + + self.stats.iterations = iteration + 1; + + // Check if any optimizations were performed + if self.stats.constants_folded == before_stats.constants_folded + && self.stats.dead_code_removed == before_stats.dead_code_removed + && self.stats.unused_bindings_removed == before_stats.unused_bindings_removed + && self.stats.gates_cancelled == before_stats.gates_cancelled + && self.stats.identities_removed == before_stats.identities_removed + { + // No changes, stop iterating + break; + } + } + + result + } + + /// Run one optimization pass. + fn optimize_pass(&mut self, program: Program) -> Program { + let mut declarations = Vec::new(); + + for decl in program.declarations { + declarations.push(self.optimize_decl(decl)); + } + + Program { + name: program.name, + declarations, + location: program.location, + } + } + + /// Optimize a top-level declaration. + fn optimize_decl(&mut self, decl: TopLevelDecl) -> TopLevelDecl { + match decl { + TopLevelDecl::Fn(mut fn_decl) => { + fn_decl.body = self.optimize_block(fn_decl.body); + TopLevelDecl::Fn(fn_decl) + } + TopLevelDecl::Binding(binding) => TopLevelDecl::Binding(self.optimize_binding(binding)), + other => other, + } + } + + /// Optimize a block of statements. + fn optimize_block(&mut self, block: Block) -> Block { + let mut statements = Vec::new(); + + // First pass: optimize individual statements + for stmt in block.statements { + if let Some(optimized) = self.optimize_stmt(stmt) { + statements.push(optimized); + } + } + + // Gate cancellation pass + if self.config.gate_cancellation { + statements = self.cancel_gates(statements); + } + + // Unused binding elimination + if self.config.unused_binding_elimination { + statements = self.eliminate_unused_bindings(statements); + } + + Block { + label: block.label, + attrs: block.attrs, + statements, + trailing_expr: block.trailing_expr, + location: block.location, + } + } + + /// Optimize a statement. Returns None if the statement should be removed. + fn optimize_stmt(&mut self, stmt: Stmt) -> Option { + match stmt { + Stmt::Binding(binding) => Some(Stmt::Binding(self.optimize_binding(binding))), + + Stmt::Expr(expr_stmt) => { + // Check for identity gates (e.g., rz(0)) before optimization + if self.config.identity_removal + && let Expr::Gate(ref gate) = expr_stmt.expr + && let Some(angle) = gate.params.first() + && self.is_zero_angle(angle) + { + self.stats.identities_removed += 1; + return None; // Remove the identity gate + } + let optimized = self.optimize_expr(expr_stmt.expr); + Some(Stmt::Expr(crate::ast::ExprStmt { + expr: optimized, + attrs: expr_stmt.attrs, + location: expr_stmt.location, + })) + } + + Stmt::If(if_stmt) => self.optimize_if(if_stmt), + + Stmt::For(for_stmt) => { + // Try to unroll inline for loops + if self.config.inline_for_unrolling + && for_stmt.is_inline + && let Some(unrolled) = self.try_unroll_inline_for(&for_stmt) + { + // Return the unrolled statements as a block + return Some(Stmt::Block(Block { + label: for_stmt.label.clone(), + attrs: vec![], + statements: unrolled, + trailing_expr: None, + location: for_stmt.location.clone(), + })); + } + Some(Stmt::For(self.optimize_for(for_stmt))) + } + + Stmt::Block(block) => Some(Stmt::Block(self.optimize_block(block))), + + Stmt::Gate(gate_op) => self.optimize_gate_stmt(gate_op), + + Stmt::Return(ret) => { + let mut optimized = ret; + if let Some(expr) = optimized.value { + optimized.value = Some(self.optimize_expr(expr)); + } + Some(Stmt::Return(optimized)) + } + + Stmt::Tick(tick_stmt) => { + // Optimize statements within the tick block individually + // Note: The tick block itself acts as a barrier for cross-block optimization, + // but gates WITHIN the same tick can still cancel each other + let optimized_stmts: Vec = tick_stmt + .body + .into_iter() + .filter_map(|stmt| self.optimize_stmt(stmt)) + .collect(); + + // Apply gate cancellation within the tick + let optimized_stmts = if self.config.gate_cancellation { + self.cancel_gates(optimized_stmts) + } else { + optimized_stmts + }; + + Some(Stmt::Tick(crate::ast::TickStmt { + label: tick_stmt.label, + body: optimized_stmts, + attrs: tick_stmt.attrs, + location: tick_stmt.location, + })) + } + + // Pass through other statements + other => Some(other), + } + } + + /// Optimize a binding. + fn optimize_binding(&mut self, binding: Binding) -> Binding { + let mut optimized = binding; + if let Some(value) = optimized.value { + optimized.value = Some(self.optimize_expr(value)); + } + optimized + } + + /// Optimize an expression. + fn optimize_expr(&mut self, expr: Expr) -> Expr { + // Try constant folding first + if self.config.constant_folding + && let Some(folded) = self.try_fold_constant(&expr) + { + self.stats.constants_folded += 1; + return folded; + } + + // Recursively optimize subexpressions + match expr { + Expr::Binary(bin) => { + let left = self.optimize_expr(bin.left.clone()); + let right = self.optimize_expr(bin.right.clone()); + + // Try folding after optimizing children + let new_bin = BinaryExpr { + op: bin.op, + left, + right, + location: bin.location, + }; + + if self.config.constant_folding + && let Some(folded) = self.try_fold_binary(&new_bin) + { + self.stats.constants_folded += 1; + return folded; + } + + Expr::Binary(Box::new(new_bin)) + } + + Expr::Unary(un) => { + let operand = self.optimize_expr(un.operand.clone()); + + let new_un = UnaryExpr { + op: un.op, + operand, + location: un.location, + }; + + if self.config.constant_folding + && let Some(folded) = self.try_fold_unary(&new_un) + { + self.stats.constants_folded += 1; + return folded; + } + + Expr::Unary(Box::new(new_un)) + } + + Expr::Call(mut call) => { + call.args = call + .args + .into_iter() + .map(|a| self.optimize_expr(a)) + .collect(); + Expr::Call(call) + } + + Expr::Index(mut idx) => { + idx.object = self.optimize_expr(idx.object); + idx.index = self.optimize_expr(idx.index); + Expr::Index(idx) + } + + Expr::Field(mut field) => { + field.object = self.optimize_expr(field.object); + Expr::Field(field) + } + + Expr::Tuple(mut tuple) => { + tuple.elements = tuple + .elements + .into_iter() + .map(|e| self.optimize_expr(e)) + .collect(); + Expr::Tuple(tuple) + } + + Expr::BracketArray(mut arr) => { + arr.elements = arr + .elements + .into_iter() + .map(|e| self.optimize_expr(e)) + .collect(); + Expr::BracketArray(arr) + } + + // Pass through other expressions + other => other, + } + } + + /// Try to fold an expression to a constant. + fn try_fold_constant(&mut self, expr: &Expr) -> Option { + match self.evaluator.eval_expr(expr) { + Ok(value) => self.comptime_to_expr(&value, expr), + Err(_) => None, + } + } + + /// Try to fold a binary expression. + fn try_fold_binary(&mut self, bin: &BinaryExpr) -> Option { + // Check for identity operations + match (&bin.left, bin.op, &bin.right) { + // x + 0 = x, x - 0 = x + (_, BinaryOp::Add | BinaryOp::Sub, Expr::IntLit(IntLit { value: 0, .. })) => { + return Some(bin.left.clone()); + } + // 0 + x = x + (Expr::IntLit(IntLit { value: 0, .. }), BinaryOp::Add, _) => { + return Some(bin.right.clone()); + } + // x * 1 = x, x / 1 = x + (_, BinaryOp::Mul | BinaryOp::Div, Expr::IntLit(IntLit { value: 1, .. })) => { + return Some(bin.left.clone()); + } + // 1 * x = x + (Expr::IntLit(IntLit { value: 1, .. }), BinaryOp::Mul, _) => { + return Some(bin.right.clone()); + } + // x * 0 = 0 + (_, BinaryOp::Mul, Expr::IntLit(IntLit { value: 0, .. })) => { + return Some(Expr::IntLit(IntLit { + value: 0, + suffix: None, + location: bin.location.clone(), + })); + } + // 0 * x = 0 + (Expr::IntLit(IntLit { value: 0, .. }), BinaryOp::Mul, _) => { + return Some(Expr::IntLit(IntLit { + value: 0, + suffix: None, + location: bin.location.clone(), + })); + } + // x && true = x, x || false = x + (_, BinaryOp::And, Expr::BoolLit(BoolLit { value: true, .. })) => { + return Some(bin.left.clone()); + } + (_, BinaryOp::Or, Expr::BoolLit(BoolLit { value: false, .. })) => { + return Some(bin.left.clone()); + } + // true && x = x, false || x = x + (Expr::BoolLit(BoolLit { value: true, .. }), BinaryOp::And, _) => { + return Some(bin.right.clone()); + } + (Expr::BoolLit(BoolLit { value: false, .. }), BinaryOp::Or, _) => { + return Some(bin.right.clone()); + } + // x && false = false, x || true = true + (_, BinaryOp::And, Expr::BoolLit(BoolLit { value: false, .. })) => { + return Some(Expr::BoolLit(BoolLit { + value: false, + location: bin.location.clone(), + })); + } + (_, BinaryOp::Or, Expr::BoolLit(BoolLit { value: true, .. })) => { + return Some(Expr::BoolLit(BoolLit { + value: true, + location: bin.location.clone(), + })); + } + _ => {} + } + + // Try full constant evaluation + self.try_fold_constant(&Expr::Binary(Box::new(bin.clone()))) + } + + /// Try to fold a unary expression. + fn try_fold_unary(&mut self, un: &UnaryExpr) -> Option { + // Double negation: --x = x, !!x = x + if let Expr::Unary(inner) = &un.operand + && un.op == inner.op + && matches!(un.op, UnaryOp::Neg | UnaryOp::Not) + { + return Some(inner.operand.clone()); + } + + // Try full constant evaluation + self.try_fold_constant(&Expr::Unary(Box::new(un.clone()))) + } + + /// Convert a comptime value to an AST expression. + fn comptime_to_expr(&self, value: &ComptimeValue, original: &Expr) -> Option { + let location = match original { + Expr::Binary(b) => b.location.clone(), + Expr::Unary(u) => u.location.clone(), + Expr::IntLit(i) => i.location.clone(), + Expr::FloatLit(f) => f.location.clone(), + Expr::BoolLit(b) => b.location.clone(), + _ => None, + }; + + match value { + ComptimeValue::Int(v) => Some(Expr::IntLit(IntLit { + value: *v as i128, + suffix: None, + location, + })), + ComptimeValue::Uint(v) => Some(Expr::IntLit(IntLit { + value: *v as i128, + suffix: None, + location, + })), + ComptimeValue::Float(v) => Some(Expr::FloatLit(FloatLit { + value: *v, + suffix: None, + location, + })), + ComptimeValue::Bool(v) => Some(Expr::BoolLit(BoolLit { + value: *v, + location, + })), + _ => None, // Don't fold complex types + } + } + + /// Optimize an if statement. + fn optimize_if(&mut self, if_stmt: IfStmt) -> Option { + let condition = self.optimize_expr(if_stmt.condition); + + // Check for constant condition + if self.config.dead_code_elimination + && let Expr::BoolLit(BoolLit { value, .. }) = &condition + { + self.stats.dead_code_removed += 1; + if *value { + // Condition is always true - keep then branch + return Some(Stmt::Block(self.optimize_block(if_stmt.then_body))); + } else { + // Condition is always false - keep else branch or remove + return match if_stmt.else_body { + Some(ElseBranch::Else(block)) => Some(Stmt::Block(self.optimize_block(block))), + Some(ElseBranch::ElseIf(nested)) => self.optimize_if(*nested), + None => None, + }; + } + } + + let then_body = self.optimize_block(if_stmt.then_body); + let else_body = match if_stmt.else_body { + Some(ElseBranch::Else(block)) => Some(ElseBranch::Else(self.optimize_block(block))), + Some(ElseBranch::ElseIf(nested)) => { + if let Some(Stmt::If(optimized)) = self.optimize_if(*nested) { + Some(ElseBranch::ElseIf(Box::new(optimized))) + } else { + None + } + } + None => None, + }; + + Some(Stmt::If(IfStmt { + condition, + capture: if_stmt.capture, + then_body, + else_body, + location: if_stmt.location, + })) + } + + /// Optimize a for statement. + fn optimize_for(&mut self, for_stmt: ForStmt) -> ForStmt { + let body = self.optimize_block(for_stmt.body); + + // Optimize range bounds + let range = match for_stmt.range { + ForRange::Range { start, end } => ForRange::Range { + start: self.optimize_expr(start), + end: self.optimize_expr(end), + }, + ForRange::Collection(expr) => ForRange::Collection(self.optimize_expr(expr)), + }; + + ForStmt { + label: for_stmt.label, + is_inline: for_stmt.is_inline, + range, + captures: for_stmt.captures, + body, + location: for_stmt.location, + } + } + + // ========================================================================= + // Inline For Loop Unrolling + // ========================================================================= + + /// Try to unroll an inline for loop. + /// + /// Returns `Some(unrolled_statements)` if the loop can be unrolled, `None` otherwise. + /// Unrolling is possible when: + /// - The loop is marked as `inline` + /// - The range bounds are comptime-evaluable + /// - The iteration count is within the safety limit + fn try_unroll_inline_for(&mut self, for_stmt: &ForStmt) -> Option> { + if !for_stmt.is_inline { + return None; + } + + // Evaluate range bounds at comptime + let (start, end) = match &for_stmt.range { + ForRange::Range { start, end } => { + let start_val = self.evaluator.eval_expr(start).ok()?.as_int()?; + let end_val = self.evaluator.eval_expr(end).ok()?.as_int()?; + (start_val, end_val) + } + ForRange::Collection(expr) => { + // For collections, evaluate and get the length + let val = self.evaluator.eval_expr(expr).ok()?; + match val { + ComptimeValue::Array(arr) => (0, arr.len() as i64), + _ => return None, + } + } + }; + + // Check iteration count against safety limit + let iteration_count = (end - start).max(0) as usize; + if iteration_count > self.config.max_inline_for_iterations { + return None; + } + + // Get the capture variable name + let capture_name = for_stmt.captures.first()?; + + // Generate unrolled statements + let mut unrolled = Vec::with_capacity(iteration_count * for_stmt.body.statements.len()); + for i in start..end { + // Substitute the loop variable with the concrete value in each statement + for stmt in &for_stmt.body.statements { + let substituted = self.substitute_in_stmt(stmt, capture_name, i); + // Recursively optimize the substituted statement (handles nested inline for) + if let Some(optimized) = self.optimize_stmt(substituted) { + // If the optimized statement is a block (from nested unrolling), flatten it + match optimized { + Stmt::Block(block) => unrolled.extend(block.statements), + other => unrolled.push(other), + } + } + } + } + + self.stats.inline_for_unrolled += 1; + self.stats.inline_for_statements_generated += unrolled.len(); + + Some(unrolled) + } + + /// Substitute a variable with a concrete integer value in a statement. + fn substitute_in_stmt(&self, stmt: &Stmt, var_name: &str, value: i64) -> Stmt { + match stmt { + Stmt::Binding(binding) => Stmt::Binding(Binding { + name: binding.name.clone(), + ty: binding.ty.clone(), + value: binding + .value + .as_ref() + .map(|e| self.substitute_in_expr(e, var_name, value)), + is_mutable: binding.is_mutable, + is_pub: binding.is_pub, + doc_comment: binding.doc_comment.clone(), + location: binding.location.clone(), + }), + + Stmt::Expr(expr_stmt) => Stmt::Expr(crate::ast::ExprStmt { + expr: self.substitute_in_expr(&expr_stmt.expr, var_name, value), + attrs: expr_stmt.attrs.clone(), + location: expr_stmt.location.clone(), + }), + + Stmt::Gate(gate_op) => Stmt::Gate(GateOp { + kind: gate_op.kind, + targets: gate_op + .targets + .iter() + .map(|t| self.substitute_in_slot_ref(t, var_name, value)) + .collect(), + params: gate_op + .params + .iter() + .map(|e| self.substitute_in_expr(e, var_name, value)) + .collect(), + attrs: gate_op.attrs.clone(), + location: gate_op.location.clone(), + }), + + Stmt::If(if_stmt) => Stmt::If(IfStmt { + condition: self.substitute_in_expr(&if_stmt.condition, var_name, value), + capture: if_stmt.capture.clone(), + then_body: self.substitute_in_block(&if_stmt.then_body, var_name, value), + else_body: if_stmt.else_body.as_ref().map(|eb| match eb { + ElseBranch::Else(block) => { + ElseBranch::Else(self.substitute_in_block(block, var_name, value)) + } + ElseBranch::ElseIf(nested) => { + if let Stmt::If(nested_if) = + self.substitute_in_stmt(&Stmt::If(*nested.clone()), var_name, value) + { + ElseBranch::ElseIf(Box::new(nested_if)) + } else { + eb.clone() + } + } + }), + location: if_stmt.location.clone(), + }), + + Stmt::For(for_stmt) => Stmt::For(ForStmt { + label: for_stmt.label.clone(), + is_inline: for_stmt.is_inline, + range: match &for_stmt.range { + ForRange::Range { start, end } => ForRange::Range { + start: self.substitute_in_expr(start, var_name, value), + end: self.substitute_in_expr(end, var_name, value), + }, + ForRange::Collection(expr) => { + ForRange::Collection(self.substitute_in_expr(expr, var_name, value)) + } + }, + captures: for_stmt.captures.clone(), + body: self.substitute_in_block(&for_stmt.body, var_name, value), + location: for_stmt.location.clone(), + }), + + Stmt::Block(block) => Stmt::Block(self.substitute_in_block(block, var_name, value)), + + Stmt::Return(ret) => Stmt::Return(crate::ast::ReturnStmt { + value: ret + .value + .as_ref() + .map(|e| self.substitute_in_expr(e, var_name, value)), + location: ret.location.clone(), + }), + + Stmt::Assign(assign) => Stmt::Assign(crate::ast::AssignStmt { + target: self.substitute_in_expr(&assign.target, var_name, value), + op: assign.op, + value: self.substitute_in_expr(&assign.value, var_name, value), + location: assign.location.clone(), + }), + + Stmt::Tick(tick) => Stmt::Tick(crate::ast::TickStmt { + label: tick.label.clone(), + attrs: tick.attrs.clone(), + body: tick + .body + .iter() + .map(|s| self.substitute_in_stmt(s, var_name, value)) + .collect(), + location: tick.location.clone(), + }), + + // Pass through statements that don't contain expressions + other => other.clone(), + } + } + + /// Substitute a variable with a concrete integer value in a block. + fn substitute_in_block(&self, block: &Block, var_name: &str, value: i64) -> Block { + Block { + label: block.label.clone(), + attrs: block.attrs.clone(), + statements: block + .statements + .iter() + .map(|s| self.substitute_in_stmt(s, var_name, value)) + .collect(), + trailing_expr: block + .trailing_expr + .as_ref() + .map(|e| Box::new(self.substitute_in_expr(e, var_name, value))), + location: block.location.clone(), + } + } + + /// Substitute a variable with a concrete integer value in an expression. + fn substitute_in_expr(&self, expr: &Expr, var_name: &str, value: i64) -> Expr { + match expr { + Expr::Ident(ident) if ident.name == var_name => { + // Replace identifier with the concrete value + Expr::IntLit(IntLit { + value: value as i128, + suffix: None, + location: ident.location.clone(), + }) + } + + Expr::Binary(bin) => Expr::Binary(Box::new(BinaryExpr { + op: bin.op, + left: self.substitute_in_expr(&bin.left, var_name, value), + right: self.substitute_in_expr(&bin.right, var_name, value), + location: bin.location.clone(), + })), + + Expr::Unary(un) => Expr::Unary(Box::new(UnaryExpr { + op: un.op, + operand: self.substitute_in_expr(&un.operand, var_name, value), + location: un.location.clone(), + })), + + Expr::Index(idx) => Expr::Index(Box::new(crate::ast::IndexExpr { + object: self.substitute_in_expr(&idx.object, var_name, value), + index: self.substitute_in_expr(&idx.index, var_name, value), + location: idx.location.clone(), + })), + + Expr::Field(field) => Expr::Field(Box::new(crate::ast::FieldExpr { + object: self.substitute_in_expr(&field.object, var_name, value), + field: field.field.clone(), + location: field.location.clone(), + })), + + Expr::Call(call) => Expr::Call(Box::new(crate::ast::CallExpr { + callee: self.substitute_in_expr(&call.callee, var_name, value), + args: call + .args + .iter() + .map(|a| self.substitute_in_expr(a, var_name, value)) + .collect(), + location: call.location.clone(), + })), + + Expr::Tuple(tuple) => Expr::Tuple(Box::new(crate::ast::TupleExpr { + elements: tuple + .elements + .iter() + .map(|e| self.substitute_in_expr(e, var_name, value)) + .collect(), + location: tuple.location.clone(), + })), + + Expr::BracketArray(arr) => Expr::BracketArray(Box::new(crate::ast::BracketArrayExpr { + elements: arr + .elements + .iter() + .map(|e| self.substitute_in_expr(e, var_name, value)) + .collect(), + location: arr.location.clone(), + })), + + Expr::Gate(gate) => Expr::Gate(Box::new(crate::ast::GateExpr { + kind: gate.kind, + params: gate + .params + .iter() + .map(|p| self.substitute_in_expr(p, var_name, value)) + .collect(), + target: self.substitute_in_expr(&gate.target, var_name, value), + location: gate.location.clone(), + })), + + Expr::SlotRef(slot) => { + Expr::SlotRef(Box::new(self.substitute_in_slot_ref(slot, var_name, value))) + } + + Expr::If(if_expr) => Expr::If(Box::new(crate::ast::IfExpr { + condition: self.substitute_in_expr(&if_expr.condition, var_name, value), + then_expr: self.substitute_in_expr(&if_expr.then_expr, var_name, value), + else_expr: self.substitute_in_expr(&if_expr.else_expr, var_name, value), + location: if_expr.location.clone(), + })), + + // Pass through expressions that don't contain the variable + other => other.clone(), + } + } + + /// Substitute a variable in a slot reference. + fn substitute_in_slot_ref(&self, slot: &SlotRef, var_name: &str, value: i64) -> SlotRef { + SlotRef { + allocator: slot.allocator.clone(), + index: Box::new(self.substitute_in_expr(&slot.index, var_name, value)), + location: slot.location.clone(), + } + } + + /// Optimize a gate statement. + fn optimize_gate_stmt(&mut self, gate_op: GateOp) -> Option { + // Remove identity rotations (rotation by 0) + if self.config.identity_removal + && let Some(angle) = gate_op.params.first() + && self.is_zero_angle(angle) + { + self.stats.identities_removed += 1; + return None; + } + + Some(Stmt::Gate(gate_op)) + } + + /// Check if an expression represents a zero angle. + fn is_zero_angle(&self, expr: &Expr) -> bool { + match expr { + Expr::IntLit(IntLit { value: 0, .. }) => true, + Expr::FloatLit(FloatLit { value, .. }) if *value == 0.0 => true, + Expr::AngleLit(angle_lit) => self.is_zero_angle(&angle_lit.value), + _ => false, + } + } + + /// Cancel adjacent inverse gates. + /// + /// Respects optimization barriers: + /// - `@attr(preserve, ...)` attribute on gates prevents cancellation + /// - `@attr(round, n)` attribute changes act as barriers (different rounds don't cancel) + /// - `@attr(timing, ...)` attribute preserves gates for timing purposes + /// - `@attr(identity, ...)` explicitly marks intentional identity operations + /// - Blocks with `@attr(noopt, ...)` act as optimization barriers + fn cancel_gates(&mut self, statements: Vec) -> Vec { + let mut result = Vec::new(); + let mut current_round: Option = None; + + let mut i = 0; + while i < statements.len() { + let stmt = &statements[i]; + + // Check for optimization barriers + if self.is_optimization_barrier(stmt) { + result.push(statements[i].clone()); + i += 1; + continue; + } + + // Track round changes - different rounds don't cancel across + if let Some(round) = self.get_round_attr(stmt) { + if current_round.is_some() && current_round != Some(round) { + // Round changed - this is a barrier + current_round = Some(round); + result.push(statements[i].clone()); + i += 1; + continue; + } + current_round = Some(round); + } + + // Check if this and the next statement are inverse gates that can cancel + // Gates can be either Stmt::Gate(GateOp) or Stmt::Expr(ExprStmt { expr: Expr::Gate(...) }) + if i + 1 < statements.len() + && let (Some((g1, attrs1, no_opt1)), Some((g2, attrs2, no_opt2))) = ( + self.extract_gate_info(stmt), + self.extract_gate_info(&statements[i + 1]), + ) + { + // Don't cancel if either gate is wrapped in @no_optimize or has preserve attr + if !no_opt1 + && !no_opt2 + && !self.has_preserve_attr(&attrs1) + && !self.has_preserve_attr(&attrs2) + && !self.is_optimization_barrier(&statements[i + 1]) + && self.are_inverse_gate_exprs(&g1, &g2) + { + self.stats.gates_cancelled += 2; + i += 2; // Skip both gates + continue; + } + } + + result.push(statements[i].clone()); + i += 1; + } + + result + } + + /// Extract gate info from a statement (handles both Stmt::Gate and Stmt::Expr with Expr::Gate). + /// Also handles @no_optimize(gate_expr) wrapped gates. + fn extract_gate_info( + &self, + stmt: &Stmt, + ) -> Option<(crate::ast::GateExpr, Vec, bool)> { + match stmt { + Stmt::Gate(gate_op) => { + // Convert GateOp to GateExpr-like structure + // For GateOp, we need to convert targets to a single Expr + let target = if gate_op.targets.len() == 1 { + Expr::SlotRef(Box::new(gate_op.targets[0].clone())) + } else { + // Multiple targets - create a tuple + Expr::Tuple(Box::new(crate::ast::TupleExpr { + elements: gate_op + .targets + .iter() + .map(|t| Expr::SlotRef(Box::new(t.clone()))) + .collect(), + location: gate_op.location.clone(), + })) + }; + Some(( + crate::ast::GateExpr { + kind: gate_op.kind, + params: gate_op.params.clone(), + target, + location: gate_op.location.clone(), + }, + gate_op.attrs.clone(), + false, // not wrapped in @no_optimize + )) + } + Stmt::Expr(expr_stmt) => { + // Check for @no_optimize(gate_expr) builtin + if let Expr::Builtin(builtin) = &expr_stmt.expr + && builtin.name == "no_optimize" + && builtin.args.len() == 1 + && let Expr::Gate(gate_expr) = &builtin.args[0] + { + return Some(( + *gate_expr.clone(), + expr_stmt.attrs.clone(), + true, // wrapped in @no_optimize - should not be cancelled + )); + } + // Regular gate expression + if let Expr::Gate(gate_expr) = &expr_stmt.expr { + Some((*gate_expr.clone(), expr_stmt.attrs.clone(), false)) + } else { + None + } + } + _ => None, + } + } + + /// Check if two gate expressions are inverses (cancel each other). + fn are_inverse_gate_exprs(&self, g1: &crate::ast::GateExpr, g2: &crate::ast::GateExpr) -> bool { + // Must have same target + if !self.same_gate_target(&g1.target, &g2.target) { + return false; + } + + // Check for self-inverse gates + match (g1.kind, g2.kind) { + // H H = I, X X = I, Y Y = I, Z Z = I + (GateKind::H, GateKind::H) + | (GateKind::X, GateKind::X) + | (GateKind::Y, GateKind::Y) + | (GateKind::Z, GateKind::Z) => true, + + // T Tdg = I, Tdg T = I + (GateKind::T, GateKind::Tdg) | (GateKind::Tdg, GateKind::T) => true, + + // SX SXdg = I, SZ SZdg = I (SZ is the S gate), etc. + (GateKind::SX, GateKind::SXdg) | (GateKind::SXdg, GateKind::SX) => true, + (GateKind::SY, GateKind::SYdg) | (GateKind::SYdg, GateKind::SY) => true, + (GateKind::SZ, GateKind::SZdg) | (GateKind::SZdg, GateKind::SZ) => true, + + // F Fdg = I, etc. + (GateKind::F, GateKind::Fdg) | (GateKind::Fdg, GateKind::F) => true, + (GateKind::F4, GateKind::F4dg) | (GateKind::F4dg, GateKind::F4) => true, + + // Two-qubit self-inverse: CX CX = I, CZ CZ = I, SWAP SWAP = I + (GateKind::CX, GateKind::CX) + | (GateKind::CY, GateKind::CY) + | (GateKind::CZ, GateKind::CZ) + | (GateKind::CH, GateKind::CH) + | (GateKind::SWAP, GateKind::SWAP) => true, + + // Rotation cancellation: RX(a) RX(-a) = I + (GateKind::RX, GateKind::RX) + | (GateKind::RY, GateKind::RY) + | (GateKind::RZ, GateKind::RZ) => self.are_inverse_rotations(&g1.params, &g2.params), + + _ => false, + } + } + + /// Check if two gate targets are the same. + fn same_gate_target(&self, t1: &Expr, t2: &Expr) -> bool { + match (t1, t2) { + // Single qubit: compare SlotRef + (Expr::SlotRef(s1), Expr::SlotRef(s2)) => self.same_slot_ref(s1, s2), + // Index expressions (q[0], q[1], etc.) + (Expr::Index(idx1), Expr::Index(idx2)) => self.same_index_expr(idx1, idx2), + // Tuple targets (for multi-qubit gates like cx (q[0], q[1])) + (Expr::Tuple(tup1), Expr::Tuple(tup2)) => { + if tup1.elements.len() != tup2.elements.len() { + return false; + } + tup1.elements + .iter() + .zip(tup2.elements.iter()) + .all(|(a, b)| self.same_gate_target(a, b)) + } + _ => false, + } + } + + /// Check if two index expressions are the same (e.g., q[0] == q[0]). + fn same_index_expr(&self, idx1: &crate::ast::IndexExpr, idx2: &crate::ast::IndexExpr) -> bool { + // Compare the object (e.g., 'q' in q[0]) + let same_object = match (&idx1.object, &idx2.object) { + (Expr::Ident(id1), Expr::Ident(id2)) => id1.name == id2.name, + _ => false, + }; + if !same_object { + return false; + } + // Compare the index (e.g., '0' in q[0]) + match (&idx1.index, &idx2.index) { + (Expr::IntLit(lit1), Expr::IntLit(lit2)) => lit1.value == lit2.value, + _ => false, + } + } + + /// Check if two SlotRefs are the same. + fn same_slot_ref(&self, s1: &SlotRef, s2: &SlotRef) -> bool { + if s1.allocator != s2.allocator { + return false; + } + // Compare indices + match (&*s1.index, &*s2.index) { + (Expr::IntLit(IntLit { value: v1, .. }), Expr::IntLit(IntLit { value: v2, .. })) => { + v1 == v2 + } + _ => false, + } + } + + /// Check if a statement acts as an optimization barrier. + /// + /// Use `@preserve {}` blocks or `tick {}` to prevent optimization across boundaries. + fn is_optimization_barrier(&self, stmt: &Stmt) -> bool { + match stmt { + Stmt::Gate(gate) => self.has_preserve_attr(&gate.attrs), + Stmt::Block(block) => self.has_preserve_attr(&block.attrs), + // Tick blocks always act as barriers - they represent time slices + // Gates in different ticks shouldn't cancel across the boundary + Stmt::Tick(_) => true, + _ => false, + } + } + + /// Check if attributes contain a preserve marker. + /// + /// Recognized preserve attributes: + /// - `@preserve` - explicit preserve + /// - `@timing` - preserved for timing purposes + /// - `@identity` - intentional identity operation + /// - `@noopt` - no optimization + /// + /// Note: `tick {}` blocks are handled separately as always being barriers. + fn has_preserve_attr(&self, attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| { + matches!( + attr.name.as_str(), + "preserve" | "timing" | "identity" | "noopt" + ) + }) + } + + /// Get the round number from a @round(n) attribute, if present. + fn get_round_attr(&self, stmt: &Stmt) -> Option { + let attrs = match stmt { + Stmt::Gate(gate) => &gate.attrs, + Stmt::Block(block) => &block.attrs, + Stmt::Tick(tick) => &tick.attrs, + _ => return None, + }; + + for attr in attrs { + if attr.name == "round" + && let Some(crate::ast::AttributeValue::Int(n)) = &attr.value + { + return Some(*n); + } + } + None + } + + /// Check if two gates are inverses (cancel each other). + fn are_inverse_gates(&self, g1: &GateOp, g2: &GateOp) -> bool { + // Must have same targets + if !self.same_targets(&g1.targets, &g2.targets) { + return false; + } + + // Check for self-inverse gates + match (g1.kind, g2.kind) { + // H H = I, X X = I, Y Y = I, Z Z = I + (GateKind::H, GateKind::H) + | (GateKind::X, GateKind::X) + | (GateKind::Y, GateKind::Y) + | (GateKind::Z, GateKind::Z) => true, + + // T Tdg = I, Tdg T = I + (GateKind::T, GateKind::Tdg) | (GateKind::Tdg, GateKind::T) => true, + + // SX SXdg = I, SZ SZdg = I (SZ is the S gate), etc. + (GateKind::SX, GateKind::SXdg) | (GateKind::SXdg, GateKind::SX) => true, + (GateKind::SY, GateKind::SYdg) | (GateKind::SYdg, GateKind::SY) => true, + (GateKind::SZ, GateKind::SZdg) | (GateKind::SZdg, GateKind::SZ) => true, + + // F Fdg = I, etc. + (GateKind::F, GateKind::Fdg) | (GateKind::Fdg, GateKind::F) => true, + (GateKind::F4, GateKind::F4dg) | (GateKind::F4dg, GateKind::F4) => true, + + // Two-qubit self-inverse: CX CX = I, CZ CZ = I, SWAP SWAP = I + (GateKind::CX, GateKind::CX) + | (GateKind::CY, GateKind::CY) + | (GateKind::CZ, GateKind::CZ) + | (GateKind::CH, GateKind::CH) + | (GateKind::SWAP, GateKind::SWAP) => true, + + // Rotation cancellation: RX(a) RX(-a) = I + (GateKind::RX, GateKind::RX) + | (GateKind::RY, GateKind::RY) + | (GateKind::RZ, GateKind::RZ) => self.are_inverse_rotations(&g1.params, &g2.params), + + _ => false, + } + } + + /// Check if rotation parameters are inverses. + fn are_inverse_rotations(&self, params1: &[Expr], params2: &[Expr]) -> bool { + if params1.len() != 1 || params2.len() != 1 { + return false; + } + + // Simple check: if one is the negation of the other + match (¶ms1[0], ¶ms2[0]) { + (Expr::IntLit(IntLit { value: a, .. }), Expr::Unary(un)) => { + if un.op == UnaryOp::Neg + && let Expr::IntLit(IntLit { value: b, .. }) = &un.operand + { + return *a == *b; + } + false + } + (Expr::Unary(un), Expr::IntLit(IntLit { value: b, .. })) => { + if un.op == UnaryOp::Neg + && let Expr::IntLit(IntLit { value: a, .. }) = &un.operand + { + return *a == *b; + } + false + } + ( + Expr::FloatLit(FloatLit { value: a, .. }), + Expr::FloatLit(FloatLit { value: b, .. }), + ) => (*a + *b).abs() < 1e-10, + _ => false, + } + } + + /// Check if two target lists are the same. + fn same_targets(&self, t1: &[SlotRef], t2: &[SlotRef]) -> bool { + if t1.len() != t2.len() { + return false; + } + for (a, b) in t1.iter().zip(t2.iter()) { + if a.allocator != b.allocator { + return false; + } + // Compare indices + match (&*a.index, &*b.index) { + ( + Expr::IntLit(IntLit { value: v1, .. }), + Expr::IntLit(IntLit { value: v2, .. }), + ) => { + if v1 != v2 { + return false; + } + } + _ => return false, + } + } + true + } + + /// Eliminate unused bindings from a block. + fn eliminate_unused_bindings(&mut self, statements: Vec) -> Vec { + // Collect all used identifiers + let mut used: BTreeSet = BTreeSet::new(); + for stmt in &statements { + self.collect_used_identifiers(stmt, &mut used); + } + + // Filter out unused bindings (but keep those with side effects) + let mut result = Vec::new(); + for stmt in statements { + match &stmt { + Stmt::Binding(binding) => { + if used.contains(&binding.name) || self.has_side_effects(&binding.value) { + result.push(stmt); + } else { + self.stats.unused_bindings_removed += 1; + } + } + _ => result.push(stmt), + } + } + + result + } + + /// Collect all identifiers used in a statement. + fn collect_used_identifiers(&self, stmt: &Stmt, used: &mut BTreeSet) { + match stmt { + Stmt::Expr(expr_stmt) => self.collect_used_in_expr(&expr_stmt.expr, used), + Stmt::Binding(binding) => { + if let Some(value) = &binding.value { + self.collect_used_in_expr(value, used); + } + } + Stmt::If(if_stmt) => { + self.collect_used_in_expr(&if_stmt.condition, used); + for s in &if_stmt.then_body.statements { + self.collect_used_identifiers(s, used); + } + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + ElseBranch::Else(block) => { + for s in &block.statements { + self.collect_used_identifiers(s, used); + } + } + ElseBranch::ElseIf(nested) => { + self.collect_used_identifiers(&Stmt::If(*nested.clone()), used); + } + } + } + } + Stmt::For(for_stmt) => { + match &for_stmt.range { + ForRange::Range { start, end } => { + self.collect_used_in_expr(start, used); + self.collect_used_in_expr(end, used); + } + ForRange::Collection(expr) => self.collect_used_in_expr(expr, used), + } + for s in &for_stmt.body.statements { + self.collect_used_identifiers(s, used); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + self.collect_used_in_expr(value, used); + } + } + Stmt::Block(block) => { + for s in &block.statements { + self.collect_used_identifiers(s, used); + } + } + Stmt::Gate(gate) => { + for target in &gate.targets { + used.insert(target.allocator.clone()); + } + for param in &gate.params { + self.collect_used_in_expr(param, used); + } + } + _ => {} + } + } + + /// Collect identifiers used in an expression. + fn collect_used_in_expr(&self, expr: &Expr, used: &mut BTreeSet) { + match expr { + Expr::Ident(ident) => { + used.insert(ident.name.clone()); + } + Expr::Binary(bin) => { + self.collect_used_in_expr(&bin.left, used); + self.collect_used_in_expr(&bin.right, used); + } + Expr::Unary(un) => { + self.collect_used_in_expr(&un.operand, used); + } + Expr::Call(call) => { + self.collect_used_in_expr(&call.callee, used); + for arg in &call.args { + self.collect_used_in_expr(arg, used); + } + } + Expr::Index(idx) => { + self.collect_used_in_expr(&idx.object, used); + self.collect_used_in_expr(&idx.index, used); + } + Expr::Field(field) => { + self.collect_used_in_expr(&field.object, used); + } + Expr::Tuple(tuple) => { + for elem in &tuple.elements { + self.collect_used_in_expr(elem, used); + } + } + Expr::BracketArray(arr) => { + for elem in &arr.elements { + self.collect_used_in_expr(elem, used); + } + } + Expr::SlotRef(slot) => { + used.insert(slot.allocator.clone()); + self.collect_used_in_expr(&slot.index, used); + } + _ => {} + } + } + + /// Check if an expression might have side effects. + fn has_side_effects(&self, expr: &Option) -> bool { + match expr { + None => false, + Some(Expr::Call(_)) => true, // Function calls may have side effects + Some(Expr::Gate(_)) => true, // Gate expressions have side effects + Some(Expr::Measure(_)) => true, // Measurements have side effects + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + // ========================================================================= + // Constant Folding Tests + // ========================================================================= + + #[test] + fn test_constant_folding_arithmetic() { + let source = "x := 2 + 3;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // The binding should have a constant value of 5 + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 5); + } else { + panic!("Expected constant folded to IntLit"); + } + } + assert!(optimizer.stats.constants_folded > 0); + } + + #[test] + fn test_constant_folding_subtraction() { + let source = "x := 10 - 3;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 7); + } else { + panic!("Expected constant folded to IntLit"); + } + } + } + + #[test] + fn test_constant_folding_multiplication() { + let source = "x := 4 * 5;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 20); + } else { + panic!("Expected constant folded to IntLit"); + } + } + } + + #[test] + fn test_constant_folding_division() { + let source = "x := 20 / 4;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 5); + } else { + panic!("Expected constant folded to IntLit"); + } + } + } + + #[test] + fn test_constant_folding_nested_arithmetic() { + let source = "x := (2 + 3) * (4 - 1);"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 15); // (2+3) * (4-1) = 5 * 3 = 15 + } else { + panic!("Expected constant folded to IntLit"); + } + } + } + + #[test] + fn test_constant_folding_boolean_and() { + // Zlup uses 'and' keyword (not &&) + let source = "pub fn test() -> bool { x := true; return x and false; }\n"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // Verify the optimizer runs without error + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + assert_eq!(fn_decl.name, "test"); + } + } + + #[test] + fn test_constant_folding_boolean_or() { + // Zlup uses 'or' keyword (not ||) + let source = "pub fn test() -> bool { x := false; return x or true; }\n"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + assert_eq!(fn_decl.name, "test"); + } + } + + #[test] + fn test_constant_folding_comparison_eq() { + let source = "x := 5 == 5;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "5 == 5 should be true"); + } else { + panic!("Expected constant folded to BoolLit"); + } + } + } + + #[test] + fn test_constant_folding_comparison_ne() { + let source = "x := 5 != 3;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "5 != 3 should be true"); + } else { + panic!("Expected constant folded to BoolLit"); + } + } + } + + #[test] + fn test_constant_folding_comparison_lt() { + let source = "x := 3 < 5;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "3 < 5 should be true"); + } else { + panic!("Expected constant folded to BoolLit"); + } + } + } + + #[test] + fn test_constant_folding_comparison_gt() { + let source = "x := 5 > 3;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "5 > 3 should be true"); + } else { + panic!("Expected constant folded to BoolLit"); + } + } + } + + // ========================================================================= + // Identity Simplification Tests + // ========================================================================= + + #[test] + fn test_identity_simplification() { + let source = "x := y + 0;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_add_zero_left() { + let source = "x := 0 + y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "0 + y should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_subtract_zero() { + let source = "x := y - 0;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y - 0 should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_multiply_one_right() { + let source = "x := y * 1;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y * 1 should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_multiply_one_left() { + let source = "x := 1 * y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "1 * y should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_divide_one() { + let source = "x := y / 1;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y / 1 should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_multiply_zero_right() { + let source = "x := y * 0;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 0, "y * 0 should simplify to 0"); + } else { + panic!("Expected simplified to 0"); + } + } + } + + #[test] + fn test_identity_multiply_zero_left() { + let source = "x := 0 * y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 0, "0 * y should simplify to 0"); + } else { + panic!("Expected simplified to 0"); + } + } + } + + #[test] + fn test_identity_and_true_right() { + // Zlup uses 'and' keyword, not '&&' + let source = "x := y and true;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y and true should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_and_true_left() { + // Zlup uses 'and' keyword, not '&&' + let source = "x := true and y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "true and y should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_or_false_right() { + // Zlup uses 'or' keyword, not '||' + let source = "x := y or false;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y or false should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_or_false_left() { + // Zlup uses 'or' keyword, not '||' + let source = "x := false or y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "false or y should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_and_false_short_circuit() { + // Zlup uses 'and' keyword, not '&&' + let source = "x := y and false;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(!lit.value, "y and false should simplify to false"); + } else { + panic!("Expected simplified to false"); + } + } + } + + #[test] + fn test_identity_or_true_short_circuit() { + // Zlup uses 'or' keyword, not '||' + let source = "x := y or true;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "y or true should simplify to true"); + } else { + panic!("Expected simplified to true"); + } + } + } + + // ========================================================================= + // Dead Code Elimination Tests + // ========================================================================= + + #[test] + fn test_dead_code_elimination() { + let source = r#" +pub fn main() -> unit { + if false { + x := 1; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // The if statement should be removed + assert!(optimizer.stats.dead_code_removed > 0); + + // Verify the function body no longer contains an if statement + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_if = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::If(_))); + assert!(!has_if, "if statement should have been eliminated"); + } + } + + #[test] + fn test_dead_code_if_true_keeps_then_branch() { + let source = r#" +pub fn main() -> unit { + if true { + x := 42; + } else { + y := 0; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert!(optimizer.stats.dead_code_removed > 0); + + // The if should be replaced by just the then body block + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_if = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::If(_))); + assert!(!has_if, "if true should be replaced by then block"); + } + } + + #[test] + fn test_dead_code_if_false_keeps_else_branch() { + let source = r#" +pub fn main() -> unit { + if false { + x := 42; + } else { + y := 0; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert!(optimizer.stats.dead_code_removed > 0); + + // The if should be replaced by just the else body block + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_if = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::If(_))); + assert!(!has_if, "if false should be replaced by else block"); + } + } + + // ========================================================================= + // Double Negation Tests + // ========================================================================= + + #[test] + fn test_double_negation() { + let source = "x := --5;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 5); + } else { + panic!("Expected double negation folded"); + } + } + } + + #[test] + fn test_double_not() { + let source = "x := !!true;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "!!true should be true"); + } else { + panic!("Expected double not folded to BoolLit"); + } + } + } + + // ========================================================================= + // Gate Cancellation Tests + // ========================================================================= + + #[test] + fn test_gate_cancellation_h_h() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "H H should cancel"); + + // Verify no H gates remain + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let h_count = fn_decl + .body + .statements + .iter() + .filter(|s| matches!(s, Stmt::Gate(g) if g.kind == GateKind::H)) + .count(); + assert_eq!(h_count, 0, "Both H gates should be cancelled"); + } + } + + #[test] + fn test_gate_cancellation_x_x() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + x q[0]; + x q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "X X should cancel"); + } + + #[test] + fn test_gate_cancellation_y_y() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + y q[0]; + y q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "Y Y should cancel"); + } + + #[test] + fn test_gate_cancellation_z_z() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + z q[0]; + z q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "Z Z should cancel"); + } + + #[test] + fn test_gate_cancellation_sz_szdg() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + sz q[0]; + szdg q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "SZ SZdg should cancel"); + } + + #[test] + fn test_gate_cancellation_t_tdg() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + t q[0]; + tdg q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "T Tdg should cancel"); + } + + #[test] + fn test_gate_cancellation_cx_cx() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + cx (q[0], q[1]); + cx (q[0], q[1]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "CX CX should cancel"); + } + + #[test] + fn test_gate_cancellation_cz_cz() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + cz (q[0], q[1]); + cz (q[0], q[1]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "CZ CZ should cancel"); + } + + #[test] + fn test_gate_cancellation_swap_swap() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + swap (q[0], q[1]); + swap (q[0], q[1]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats.gates_cancelled, 2, + "SWAP SWAP should cancel" + ); + } + + #[test] + fn test_gate_no_cancel_different_qubits() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + h q[1]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // Should NOT cancel - different qubits + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "H on different qubits should not cancel" + ); + + // Verify both H gates remain + // Note: Gates are represented as Stmt::Expr containing Expr::Gate + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let h_count = fn_decl + .body + .statements + .iter() + .filter(|s| { + if let Stmt::Expr(expr_stmt) = s + && let Expr::Gate(gate) = &expr_stmt.expr + { + return gate.kind == GateKind::H; + } + false + }) + .count(); + assert_eq!(h_count, 2, "Both H gates on different qubits should remain"); + } + } + + #[test] + fn test_gate_no_cancel_different_types() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + x q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // Should NOT cancel - different gate types + assert_eq!(optimizer.stats.gates_cancelled, 0, "H X should not cancel"); + } + + // ========================================================================= + // Optimization Barrier Tests + // ========================================================================= + + #[test] + fn test_has_preserve_attr() { + use crate::ast::Attribute; + + let optimizer = Optimizer::new(); + + // Test @preserve + let attrs = vec![Attribute::flag("preserve")]; + assert!(optimizer.has_preserve_attr(&attrs)); + + // Test @timing + let attrs = vec![Attribute::flag("timing")]; + assert!(optimizer.has_preserve_attr(&attrs)); + + // Test @identity + let attrs = vec![Attribute::flag("identity")]; + assert!(optimizer.has_preserve_attr(&attrs)); + + // Test @noopt + let attrs = vec![Attribute::flag("noopt")]; + assert!(optimizer.has_preserve_attr(&attrs)); + + // Test non-preserve attr + let attrs = vec![Attribute::flag("other")]; + assert!(!optimizer.has_preserve_attr(&attrs)); + + // Test empty attrs + let attrs: Vec = vec![]; + assert!(!optimizer.has_preserve_attr(&attrs)); + } + + #[test] + fn test_no_optimize_prevents_gate_cancellation() { + // @no_optimize(expr) builtin prevents cancellation + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + @no_optimize(h q[0]); + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // The first H is wrapped in @no_optimize, so cancellation shouldn't happen + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "@no_optimize should prevent cancellation" + ); + } + + #[test] + fn test_no_optimize_on_second_gate_prevents_cancellation() { + // @no_optimize on the second gate also prevents cancellation + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + @no_optimize(h q[0]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "@no_optimize on second gate should prevent cancellation" + ); + } + + #[test] + fn test_no_optimize_both_gates_no_cancellation() { + // Both gates wrapped in @no_optimize + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + @no_optimize(h q[0]); + @no_optimize(h q[0]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "Both @no_optimize should prevent cancellation" + ); + } + + #[test] + fn test_tick_blocks_are_barriers() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + tick { + x q[1]; + } + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // tick {} is a barrier, so the two H gates shouldn't cancel + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "tick should act as barrier" + ); + } + + #[test] + fn test_gates_cancel_within_same_tick() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + tick { + h q[0]; + h q[0]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // Within the same tick, gates CAN cancel + assert_eq!( + optimizer.stats.gates_cancelled, 2, + "H H within same tick should cancel" + ); + } + + // ========================================================================= + // @round(n) Attribute Tests + // ========================================================================= + + #[test] + fn test_get_round_attr() { + use crate::ast::{Attribute, AttributeValue, Block}; + + let optimizer = Optimizer::new(); + + // Create a block with @round(0) + let block = Block { + label: None, + attrs: vec![Attribute::with_value("round", AttributeValue::Int(0))], + statements: vec![], + trailing_expr: None, + location: None, + }; + + let stmt = Stmt::Block(block); + assert_eq!(optimizer.get_round_attr(&stmt), Some(0)); + + // Create a block with @round(5) + let block = Block { + label: None, + attrs: vec![Attribute::with_value("round", AttributeValue::Int(5))], + statements: vec![], + trailing_expr: None, + location: None, + }; + + let stmt = Stmt::Block(block); + assert_eq!(optimizer.get_round_attr(&stmt), Some(5)); + + // Block without round attr + let block = Block { + label: None, + attrs: vec![], + statements: vec![], + trailing_expr: None, + location: None, + }; + + let stmt = Stmt::Block(block); + assert_eq!(optimizer.get_round_attr(&stmt), None); + } + + // ========================================================================= + // Unused Binding Elimination Tests + // ========================================================================= + + #[test] + fn test_unused_binding_eliminated() { + let source = r#" +pub fn main() -> unit { + unused := 42; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert!( + optimizer.stats.unused_bindings_removed > 0, + "unused binding should be removed" + ); + + // Verify the binding was removed + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_binding = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::Binding(b) if b.name == "unused")); + assert!(!has_binding, "unused binding should be eliminated"); + } + } + + #[test] + fn test_used_binding_kept() { + let source = r#" +pub fn main() -> i32 { + used := 42; + return used; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // Verify the binding was NOT removed + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_binding = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::Binding(b) if b.name == "used")); + assert!(has_binding, "used binding should be kept"); + } + } + + // ========================================================================= + // Zero-Angle Identity Removal Tests + // ========================================================================= + + #[test] + fn test_zero_rotation_removed() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + rz(0) q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert!( + optimizer.stats.identities_removed > 0, + "rz(0) should be removed as identity" + ); + + // Verify the gate was removed + // Note: Gates are represented as Stmt::Expr containing Expr::Gate + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_rz = fn_decl.body.statements.iter().any(|s| { + if let Stmt::Expr(expr_stmt) = s + && let Expr::Gate(gate) = &expr_stmt.expr + { + return gate.kind == GateKind::RZ; + } + false + }); + assert!(!has_rz, "rz(0) identity gate should be removed"); + } + } + + // ========================================================================= + // Optimizer Configuration Tests + // ========================================================================= + + #[test] + fn test_disable_constant_folding() { + let source = "x := 2 + 3;"; + let ast = parse(source).unwrap(); + let mut config = OptimizeConfig::all(); + config.constant_folding = false; + let mut optimizer = Optimizer::with_config(config); + let optimized = optimizer.optimize(ast); + + // Constant folding disabled - should still have binary expression + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + assert!( + matches!(&binding.value, Some(Expr::Binary(_))), + "with constant_folding disabled, should keep binary expr" + ); + } + assert_eq!(optimizer.stats.constants_folded, 0); + } + + #[test] + fn test_disable_gate_cancellation() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut config = OptimizeConfig::all(); + config.gate_cancellation = false; + let mut optimizer = Optimizer::with_config(config); + let optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "gate_cancellation disabled" + ); + + // Both H gates should remain + // Note: Gates are represented as Stmt::Expr containing Expr::Gate + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let h_count = fn_decl + .body + .statements + .iter() + .filter(|s| { + if let Stmt::Expr(expr_stmt) = s + && let Expr::Gate(gate) = &expr_stmt.expr + { + return gate.kind == GateKind::H; + } + false + }) + .count(); + assert_eq!( + h_count, 2, + "Both H gates should remain when cancellation disabled" + ); + } + } + + #[test] + fn test_disable_all_optimizations() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + unused := 2 + 3; + h q[0]; + h q[0]; + if false { + x q[0]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::with_config(OptimizeConfig::none()); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.constants_folded, 0); + assert_eq!(optimizer.stats.dead_code_removed, 0); + assert_eq!(optimizer.stats.unused_bindings_removed, 0); + assert_eq!(optimizer.stats.gates_cancelled, 0); + assert_eq!(optimizer.stats.identities_removed, 0); + assert_eq!(optimizer.stats.iterations, 0); + } + + // ========================================================================= + // Edge Cases + // ========================================================================= + + #[test] + fn test_multiple_gate_pairs_cancel() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + h q[0]; + x q[0]; + x q[0]; + z q[0]; + z q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats.gates_cancelled, 6, + "All 3 pairs should cancel" + ); + } + + #[test] + fn test_interleaved_gates_no_cancel() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + x q[0]; + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // H and H are not adjacent, so should not cancel + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "Non-adjacent gates should not cancel" + ); + } + + #[test] + fn test_optimizer_stats() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + x := 2 + 3; + h q[0]; + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + let stats = optimizer.stats(); + assert!(stats.constants_folded > 0); + assert!(stats.gates_cancelled >= 2); + assert!(stats.iterations >= 1); + } + + // ========================================================================= + // Inline For Loop Unrolling Tests + // ========================================================================= + + #[test] + fn test_inline_for_unrolling_basic() { + // inline for i in 0..4 { h q[i]; } should produce 4 h gates + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..4 { + h q[i]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // Verify unrolling happened + assert!( + optimizer.stats.inline_for_unrolled > 0, + "Expected inline for to be unrolled, stats: {:?}", + optimizer.stats + ); + assert_eq!( + optimizer.stats.inline_for_statements_generated, 4, + "Expected 4 statements generated from inline for" + ); + + // Verify the function body has 4 h gates + // Note: Gates can be represented as either Stmt::Gate or Stmt::Expr(Expr::Gate) + fn count_gates_recursive(stmts: &[Stmt]) -> usize { + let mut count = 0; + for stmt in stmts { + match stmt { + Stmt::Gate(_) => count += 1, + Stmt::Expr(e) => { + if matches!(e.expr, Expr::Gate(_)) { + count += 1; + } + } + Stmt::Block(b) => count += count_gates_recursive(&b.statements), + _ => {} + } + } + count + } + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let gate_count = count_gates_recursive(&fn_decl.body.statements); + // Should have pz + 4 h gates + // At minimum, we should have 4 h gates from unrolling (pz may or may not count) + assert!( + gate_count >= 4, + "Expected at least 4 gates, got {}", + gate_count + ); + } + } + + #[test] + fn test_inline_for_nested() { + // Nested 2x3 inline for should produce 6 statements + let source = r#" +pub fn main() -> unit { + q := qalloc(6); + pz q; + inline for i in 0..2 { + inline for j in 0..3 { + h q[i * 3 + j]; + } + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // Should unroll both loops: outer produces 2 iterations, + // each containing an inner loop that produces 3 statements + assert!( + optimizer.stats.inline_for_unrolled >= 2, + "Expected at least 2 inline for loops unrolled" + ); + // 2 outer iterations * 3 inner statements = 6 statements + // Plus the 2 inner loops themselves produce statements + assert!( + optimizer.stats.inline_for_statements_generated >= 6, + "Expected at least 6 statements generated, got {}", + optimizer.stats.inline_for_statements_generated + ); + } + + #[test] + fn test_inline_for_with_comptime_bounds() { + // Bounds using comptime expression (2 * 2) + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..(2 * 2) { + h q[i]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert!( + optimizer.stats.inline_for_unrolled > 0, + "Expected inline for with comptime bounds to be unrolled" + ); + assert_eq!( + optimizer.stats.inline_for_statements_generated, 4, + "Expected 4 statements from 2*2=4" + ); + } + + #[test] + fn test_inline_for_empty_range() { + // 0..0 should produce 0 statements + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..0 { + h q[i]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // Empty range still counts as unrolled (just produces 0 statements) + assert_eq!( + optimizer.stats.inline_for_statements_generated, 0, + "Expected 0 statements from empty range" + ); + } + + #[test] + fn test_regular_for_not_unrolled() { + // Regular for loop (without inline) should not be unrolled + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + for i in 0..4 { + h q[i]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats.inline_for_unrolled, 0, + "Regular for loop should not be unrolled" + ); + } +} diff --git a/exp/zlup/src/parser.rs b/exp/zlup/src/parser.rs new file mode 100644 index 000000000..3de50d842 --- /dev/null +++ b/exp/zlup/src/parser.rs @@ -0,0 +1,4189 @@ +//! Recursive descent parser for Zluppy. +//! +//! This module implements a hand-written recursive descent parser rather than +//! using the visitor pattern. This choice aligns with NASA Power of 10 principles: +//! +//! - **Explicit control flow**: No hidden dispatch mechanisms +//! - **Predictable**: Direct function calls, easy to debug +//! - **Simple**: Each grammar rule maps to a function +//! +//! The parser consumes tokens from the pest lexer and builds the AST. + +use crate::ast::*; +use pest::Parser; +use pest::iterators::{Pair, Pairs}; +use pest_derive::Parser; + +/// Pest parser generated from grammar. +#[derive(Parser)] +#[grammar = "zluppy.pest"] +struct ZluppyParser; + +/// Parser state. +pub struct ParserState<'a> { + source: &'a str, + file: Option, +} + +use thiserror::Error; + +/// Parse result type. +pub type ParseResult = Result; + +/// Parse error. +#[derive(Debug, Clone, Error)] +#[error("{message}")] +pub struct ParseError { + pub message: String, + pub location: SourceLocation, +} + +// ============================================================================= +// Input Size Limits +// ============================================================================= + +/// Maximum allowed source file size in bytes (10 MB). +/// This prevents DoS attacks via extremely large input files. +pub const MAX_SOURCE_SIZE: usize = 10 * 1024 * 1024; + +/// Maximum allowed number of AST nodes per file. +/// This prevents memory exhaustion from deeply nested or repetitive code. +pub const MAX_AST_NODES: usize = 1_000_000; + +impl<'a> ParserState<'a> { + /// Create a new parser state. + pub fn new(source: &'a str) -> Self { + Self { source, file: None } + } + + /// Set the file name for error reporting. + pub fn with_file(mut self, file: impl Into) -> Self { + self.file = Some(file.into()); + self + } + + /// Parse the source into a program AST. + pub fn parse(&self) -> ParseResult { + // Check source size limit + if self.source.len() > MAX_SOURCE_SIZE { + return Err(ParseError { + message: format!( + "source file too large: {} bytes exceeds maximum of {} bytes", + self.source.len(), + MAX_SOURCE_SIZE + ), + location: SourceLocation::default(), + }); + } + + let pairs = + ZluppyParser::parse(Rule::program, self.source).map_err(|e| self.pest_error(e))?; + + self.parse_program(pairs) + } + + /// Convert a pest error to our error type. + fn pest_error(&self, e: pest::error::Error) -> ParseError { + let (line, column, end_line, end_column) = match e.line_col { + pest::error::LineColLocation::Pos((l, c)) => { + (l as u32, c as u32, l as u32, c as u32 + 1) + } + pest::error::LineColLocation::Span((l, c), (el, ec)) => { + (l as u32, c as u32, el as u32, ec as u32) + } + }; + ParseError { + message: e.to_string(), + location: SourceLocation { + line, + column, + end_line, + end_column, + file: self.file.clone(), + }, + } + } + + /// Get source location from a pest pair. + fn location(&self, pair: &Pair) -> SourceLocation { + let span = pair.as_span(); + let (line, column) = pair.line_col(); + let (end_line, end_column) = span.end_pos().line_col(); + SourceLocation { + line: line as u32, + column: column as u32, + end_line: end_line as u32, + end_column: end_column as u32, + file: self.file.clone(), + } + } + + /// Create an error at the given pair's location. + fn error(&self, pair: &Pair, message: impl Into) -> ParseError { + ParseError { + message: message.into(), + location: self.location(pair), + } + } + + /// Create an error at an approximate location (for when we don't have a pair). + fn error_at(&self, location: SourceLocation, message: impl Into) -> ParseError { + ParseError { + message: message.into(), + location, + } + } + + /// Expect exactly one inner element from a pair. + /// Returns an error if the pair has no inner elements. + fn expect_inner(&self, pair: Pair<'a, Rule>, context: &str) -> ParseResult> { + let location = self.location(&pair); + pair.into_inner().next().ok_or_else(|| { + self.error_at(location, format!("expected inner element in {}", context)) + }) + } + + /// Expect the next element from an iterator. + /// Returns an error if the iterator is exhausted. + fn expect_next( + &self, + iter: &mut impl Iterator>, + location: &SourceLocation, + context: &str, + ) -> ParseResult> { + iter.next().ok_or_else(|| { + self.error_at( + location.clone(), + format!("expected {} but found end of input", context), + ) + }) + } + + // ========================================================================= + // Program Structure + // ========================================================================= + + /// Parse: program = { SOI ~ top_level_decl* ~ EOI } + fn parse_program(&self, pairs: Pairs) -> ParseResult { + let mut declarations = Vec::new(); + + // The pairs iterator contains a single Rule::program pair + // We need to descend into its children to find top_level_decl items + for pair in pairs { + if pair.as_rule() == Rule::program { + // Iterate over program's children + for inner_pair in pair.into_inner() { + match inner_pair.as_rule() { + Rule::top_level_decl => { + declarations.push(self.parse_top_level_decl(inner_pair)?); + } + Rule::EOI => break, + _ => {} // Skip SOI, whitespace, etc. + } + } + } + } + + Ok(Program { + name: self.file.clone().unwrap_or_else(|| "main".to_string()), + declarations, + location: None, + }) + } + + /// Parse: top_level_decl = { binding_decl | fn_decl | ... } + fn parse_top_level_decl(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "top_level_decl")?; + + match inner.as_rule() { + Rule::binding_decl => Ok(TopLevelDecl::Binding(self.parse_binding_decl(inner)?)), + Rule::fn_decl => Ok(TopLevelDecl::Fn(self.parse_fn_decl(inner)?)), + Rule::extern_fn_decl => Ok(TopLevelDecl::ExternFn(self.parse_extern_fn_decl(inner)?)), + Rule::struct_decl => Ok(TopLevelDecl::Struct(self.parse_struct_decl(inner)?)), + Rule::enum_decl => Ok(TopLevelDecl::Enum(self.parse_enum_decl(inner)?)), + Rule::union_decl => Ok(TopLevelDecl::Union(self.parse_union_decl(inner)?)), + Rule::error_set_decl => Ok(TopLevelDecl::ErrorSet(self.parse_error_set_decl(inner)?)), + Rule::fault_set_decl => Ok(TopLevelDecl::FaultSet(self.parse_fault_set_decl(inner)?)), + Rule::test_decl => Ok(TopLevelDecl::Test(self.parse_test_decl(inner)?)), + Rule::declare_gate_decl => Ok(TopLevelDecl::DeclareGate( + self.parse_declare_gate_decl(inner)?, + )), + Rule::gate_decl => Ok(TopLevelDecl::Gate(self.parse_gate_decl(inner)?)), + _ => Err(self.error(&inner, format!("unexpected {:?}", inner.as_rule()))), + } + } + + // ========================================================================= + // Declarations + // ========================================================================= + + /// Parse binding declaration. + /// New syntax: + /// x := 42; -- immutable, type inferred + /// x: i32 = 42; -- immutable, type explicit + /// mut x := 42; -- mutable, type inferred + /// mut x: i32 = 42; -- mutable, type explicit + /// Legacy syntax (backward compatible): + /// const x = 42; -- immutable + /// var x = 42; -- mutable + fn parse_binding_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut is_mutable = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut ty = None; + let mut value = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::pub_keyword => { + is_pub = true; + } + Rule::mut_keyword => { + is_mutable = true; + } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(item)?); + } + Rule::expr => { + value = Some(self.parse_expr(item)?); + } + Rule::undefined_literal => { + value = None; // Explicit undefined + } + _ => {} + } + } + + Ok(Binding { + name, + ty, + value, + is_mutable, + is_pub, + doc_comment, + location, + }) + } + + /// Parse: fn_decl = { "pub"? ~ "inline"? ~ "fn" ~ identifier ~ "(" ~ param_list? ~ ")" ~ fn_error_mode? ~ return_type? ~ block } + fn parse_fn_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut is_inline = false; + let mut error_mode = None; + let mut doc_comment = None; + let mut name = String::new(); + let mut params = Vec::new(); + let mut return_type = None; + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::fn_error_mode => { + error_mode = Some(self.parse_fn_error_mode(item)?); + } + Rule::identifier | Rule::member_name => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::param_list => { + params = self.parse_param_list(item)?; + } + Rule::return_type => { + return_type = Some(self.parse_return_type(item)?); + } + Rule::block => { + body = Some(self.parse_block(item)?); + } + _ => { + if item.as_rule() == Rule::pub_keyword { + is_pub = true; + } else if item.as_rule() == Rule::inline_keyword { + is_inline = true; + } + } + } + } + + Ok(FnDecl { + name, + params, + return_type, + body: body.expect("function must have body"), + is_pub, + is_inline, + error_mode, + doc_comment, + location, + }) + } + + /// Parse external function declaration (FFI). + /// Grammar: extern_fn_decl = { doc_comment* ~ link_attr? ~ pub_keyword? ~ "extern" ~ string_literal ~ "fn" ~ identifier ~ "(" ~ param_list? ~ ")" ~ return_type? ~ ";" } + fn parse_extern_fn_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut library = None; + let mut calling_convention = String::new(); + let mut name = String::new(); + let mut params = Vec::new(); + let mut return_type = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::link_attr => { + // Extract library name from @link("libname") + for link_item in item.into_inner() { + if link_item.as_rule() == Rule::string_literal { + let s = link_item.as_str(); + library = Some(s[1..s.len() - 1].to_string()); + } + } + } + Rule::pub_keyword => { + is_pub = true; + } + Rule::string_literal => { + // Extract the calling convention from the string literal + let s = item.as_str(); + // Remove quotes + calling_convention = s[1..s.len() - 1].to_string(); + } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); + } + Rule::param_list => { + params = self.parse_param_list(item)?; + } + Rule::return_type => { + return_type = Some(self.parse_return_type(item)?); + } + _ => {} + } + } + + Ok(ExternFnDecl { + name, + library, + calling_convention, + params, + return_type, + is_pub, + doc_comment, + location, + }) + } + + /// Parse function error mode: try or try! + fn parse_fn_error_mode(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "fn_error_mode")?; + match inner.as_rule() { + Rule::try_keyword => Ok(TryMode::Collect), + Rule::try_bang_keyword => Ok(TryMode::Propagate), + _ => Err(self.error(&inner, "expected try or try!")), + } + } + + /// Parse parameter list. + fn parse_param_list(&self, pair: Pair) -> ParseResult> { + let mut params = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::param { + params.push(self.parse_param(item)?); + } + } + Ok(params) + } + + /// Parse a single parameter. + fn parse_param(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let location = Some(loc.clone()); + let inner_pair = self.expect_inner(pair, "param")?; + + match inner_pair.as_rule() { + Rule::self_param => { + // Rust-style self receiver: &self or &mut self + let text = inner_pair.as_str(); + let is_mutable = text.contains("mut"); + + // Self type is *Self (pointer to self) or *const Self + let self_type = if is_mutable { + TypeExpr::Pointer(Box::new(PointerType { + pointee: TypeExpr::Named(TypePath { + segments: vec!["Self".to_string()], + location: location.clone(), + }), + is_const: false, + is_many: false, + sentinel: None, + })) + } else { + TypeExpr::Pointer(Box::new(PointerType { + pointee: TypeExpr::Named(TypePath { + segments: vec!["Self".to_string()], + location: location.clone(), + }), + is_const: true, + is_many: false, + sentinel: None, + })) + }; + + Ok(Param { + name: "self".to_string(), + ty: self_type, + is_comptime: false, + location, + }) + } + Rule::regular_param => { + // Regular parameter: comptime? name: type + self.parse_regular_param(inner_pair, location) + } + other => Err(ParseError { + message: format!( + "unexpected rule {:?}, expected param (self_param or regular_param)", + other + ), + location: loc, + }), + } + } + + /// Parse a regular (non-self) parameter. + fn parse_regular_param( + &self, + pair: Pair, + location: Option, + ) -> ParseResult { + let inner = pair.into_inner(); + + let mut is_comptime = false; + let mut name = String::new(); + let mut ty = None; + + for item in inner { + match item.as_rule() { + Rule::comptime_modifier => { + is_comptime = true; + } + Rule::identifier => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(item)?); + } + _ => {} + } + } + + Ok(Param { + name, + ty: ty.expect("parameter must have type"), + is_comptime, + location, + }) + } + + /// Parse return type. + fn parse_return_type(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "return_type")?; + self.parse_type_expr(inner) + } + + /// Parse struct declaration. + fn parse_struct_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut is_packed = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut fields = Vec::new(); + let mut methods = Vec::new(); + let mut associated_consts = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::struct_body => { + let (f, m, c) = self.parse_struct_body(item)?; + fields = f; + methods = m; + associated_consts = c; + } + _ => { + if item.as_rule() == Rule::pub_keyword { + is_pub = true; + } else if item.as_rule() == Rule::packed_keyword { + is_packed = true; + } + } + } + } + + Ok(StructDecl { + name, + fields, + methods, + associated_consts, + is_pub, + is_packed, + doc_comment, + location, + }) + } + + /// Parse struct body (fields, methods, and associated bindings). + fn parse_struct_body( + &self, + pair: Pair, + ) -> ParseResult<(Vec, Vec, Vec)> { + let mut fields = Vec::new(); + let mut methods = Vec::new(); + let mut associated_consts = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::struct_field => { + fields.push(self.parse_struct_field(item)?); + } + Rule::fn_decl => { + methods.push(self.parse_fn_decl(item)?); + } + Rule::binding_decl => { + // Associated constants defined within the struct + associated_consts.push(self.parse_binding_decl(item)?); + } + _ => {} + } + } + + Ok((fields, methods, associated_consts)) + } + + /// Parse struct field. + fn parse_struct_field(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut doc_comment = None; + let mut name = String::new(); + let mut ty = None; + let mut default = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier | Rule::member_name => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(item)?); + } + Rule::expr => { + default = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(StructField { + name, + ty: ty.expect("field must have type"), + default, + doc_comment, + location, + }) + } + + /// Parse enum declaration. + fn parse_enum_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut tag_type = None; + let mut variants = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::type_expr => { + tag_type = Some(self.parse_type_expr(item)?); + } + Rule::enum_body => { + variants = self.parse_enum_body(item)?; + } + _ => { + if item.as_rule() == Rule::pub_keyword { + is_pub = true; + } + } + } + } + + Ok(EnumDecl { + name, + tag_type, + variants, + is_pub, + doc_comment, + location, + }) + } + + /// Parse enum body. + fn parse_enum_body(&self, pair: Pair) -> ParseResult> { + let mut variants = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::enum_variant { + variants.push(self.parse_enum_variant(item)?); + } + } + Ok(variants) + } + + /// Parse enum variant. + fn parse_enum_variant(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = String::new(); + let mut value = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + name = item.as_str().to_string(); + } + Rule::expr => { + value = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(EnumVariant { + name, + value, + location, + }) + } + + /// Parse union declaration. + /// Grammar: union_decl = { doc_comment* ~ "pub"? ~ identifier ~ "=" ~ "union" ~ union_tag? ~ "{" ~ union_body ~ "}" ~ ";" } + fn parse_union_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut tag: Option> = None; + let mut fields = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::union_tag => { + tag = Some(self.parse_union_tag(item)?); + } + Rule::union_body => { + fields = self.parse_union_body(item)?; + } + _ => { + if item.as_rule() == Rule::pub_keyword { + is_pub = true; + } + } + } + } + + Ok(UnionDecl { + name, + tag, + fields, + is_pub, + doc_comment, + location, + }) + } + + /// Parse union tag. + /// Grammar: union_tag = { "(" ~ ("enum" | type_expr) ~ ")" } + fn parse_union_tag(&self, pair: Pair) -> ParseResult> { + let inner = pair.into_inner().next(); + match inner { + Some(item) if item.as_rule() == Rule::type_expr => { + Ok(Some(self.parse_type_expr(item)?)) + } + _ => { + // "enum" keyword means auto-tagged + Ok(None) + } + } + } + + /// Parse union body. + fn parse_union_body(&self, pair: Pair) -> ParseResult> { + let mut fields = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::union_field { + fields.push(self.parse_union_field(item)?); + } + } + Ok(fields) + } + + /// Parse union field. + /// Grammar: union_field = { identifier ~ (":" ~ type_expr)? } + fn parse_union_field(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = String::new(); + let mut ty = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(item)?); + } + _ => {} + } + } + + Ok(UnionField { name, ty, location }) + } + + /// Parse error set declaration - classical/logical errors. + /// `DecodeError := error { SyndromeAmbiguous, WeightTooHigh };` + fn parse_error_set_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut variants = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); + } + Rule::error_set_body => { + variants = self.parse_error_set_body(item)?; + } + Rule::pub_keyword => { + is_pub = true; + } + _ => {} + } + } + + Ok(ErrorSetDecl { + name, + variants, + is_pub, + doc_comment, + location, + }) + } + + /// Parse fault set declaration - quantum/physical faults. + /// `QuantumFault := fault { Leakage, QubitLoss, GateFailure };` + fn parse_fault_set_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut variants = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); + } + Rule::error_set_body => { + // Reuse error_set_body parsing for fault variants + variants = self.parse_error_set_body(item)?; + } + Rule::pub_keyword => { + is_pub = true; + } + _ => {} + } + } + + Ok(FaultSetDecl { + name, + variants, + is_pub, + doc_comment, + location, + }) + } + + /// Parse error set body. + fn parse_error_set_body(&self, pair: Pair) -> ParseResult> { + let mut variants = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::error_variant { + variants.push(self.parse_error_variant(item)?); + } + } + Ok(variants) + } + + /// Parse a single error variant. + /// Can be just a name or name with data type: `Leakage: struct { ... }` + fn parse_error_variant(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = String::new(); + let mut data_type = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + data_type = Some(self.parse_type_expr(item)?); + } + _ => {} + } + } + + Ok(ErrorVariant { + name, + data_type, + location, + }) + } + + /// Parse test declaration. + fn parse_test_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = String::new(); + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::string_literal => { + name = self.parse_string_content(item.as_str()); + } + Rule::block => { + body = Some(self.parse_block(item)?); + } + _ => {} + } + } + + Ok(TestDecl { + name, + body: body.expect("test must have body"), + location, + }) + } + + /// Parse target gate declaration: `declare gate name(params)(qubits);` + fn parse_declare_gate_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut params = Vec::new(); + let mut qubits = Vec::new(); + let mut seen_first_param_list = false; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::pub_keyword => { + is_pub = true; + } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); + } + Rule::gate_param_list => { + params = self.parse_gate_param_list(item)?; + seen_first_param_list = true; + } + Rule::qubit_param_list => { + qubits = self.parse_qubit_param_list(item)?; + } + _ => {} + } + } + // If we never saw a gate_param_list, params stays empty (no params) + let _ = seen_first_param_list; + + Ok(TargetGateDecl { + name, + params, + qubits, + is_pub, + doc_comment, + location, + }) + } + + /// Parse composite gate declaration: `gate name(params)(qubits) { body }` + fn parse_gate_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut params = Vec::new(); + let mut qubits = Vec::new(); + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::pub_keyword => { + is_pub = true; + } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); + } + Rule::gate_param_list => { + params = self.parse_gate_param_list(item)?; + } + Rule::qubit_param_list => { + qubits = self.parse_qubit_param_list(item)?; + } + Rule::block => { + body = Some(self.parse_block(item)?); + } + _ => {} + } + } + + Ok(CompositeGateDecl { + name, + params, + qubits, + body: body.expect("gate must have body"), + is_pub, + doc_comment, + location, + }) + } + + /// Parse a list of gate parameters. + fn parse_gate_param_list(&self, pair: Pair) -> ParseResult> { + let mut params = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::gate_param { + let location = Some(self.location(&item)); + let mut name = String::new(); + let mut ty = None; + for inner in item.into_inner() { + match inner.as_rule() { + Rule::identifier if name.is_empty() => { + name = inner.as_str().to_string(); + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(inner)?); + } + _ => {} + } + } + params.push(GateParam { name, ty, location }); + } + } + Ok(params) + } + + /// Parse a list of qubit parameters. + fn parse_qubit_param_list(&self, pair: Pair) -> ParseResult> { + let mut qubits = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::qubit_param { + let location = Some(self.location(&item)); + let name = item.as_str().to_string(); + qubits.push(QubitParam { name, location }); + } + } + Ok(qubits) + } + + // ========================================================================= + // Statements + // ========================================================================= + + /// Parse a statement. + /// Handles both the case where pair is a `statement` wrapper rule and + /// where pair is a specific statement type directly (e.g., from `(block | statement)` patterns). + fn parse_statement(&self, pair: Pair<'a, Rule>) -> ParseResult { + // If the pair is a `statement` rule, unwrap it to get the inner statement type + // Otherwise, use the pair directly (for cases like `(block | statement)` in grammar) + let inner = if pair.as_rule() == Rule::statement { + self.expect_inner(pair, "statement")? + } else { + pair + }; + + match inner.as_rule() { + Rule::binding_decl => Ok(Stmt::Binding(self.parse_binding_decl(inner)?)), + Rule::alias_stmt => Ok(Stmt::Alias(self.parse_alias_stmt(inner)?)), + Rule::assign_stmt => Ok(Stmt::Assign(self.parse_assign_stmt(inner)?)), + Rule::if_stmt => Ok(Stmt::If(self.parse_if_stmt(inner)?)), + Rule::for_stmt => Ok(Stmt::For(self.parse_for_stmt(inner)?)), + Rule::switch_stmt => Ok(Stmt::Switch(self.parse_switch_stmt(inner)?)), + Rule::tick_stmt => Ok(Stmt::Tick(self.parse_tick_stmt(inner)?)), + Rule::try_block_stmt => Ok(Stmt::TryBlock(self.parse_try_block_stmt(inner)?)), + Rule::return_stmt => Ok(Stmt::Return(self.parse_return_stmt(inner)?)), + Rule::break_stmt => Ok(Stmt::Break(self.parse_break_stmt(inner)?)), + Rule::continue_stmt => Ok(Stmt::Continue(self.parse_continue_stmt(inner)?)), + Rule::defer_stmt => Ok(Stmt::Defer(self.parse_defer_stmt(inner)?)), + Rule::errdefer_stmt => Ok(Stmt::Errdefer(self.parse_errdefer_stmt(inner)?)), + Rule::block => Ok(Stmt::Block(self.parse_block(inner)?)), + Rule::expr_stmt => Ok(Stmt::Expr(self.parse_expr_stmt(inner)?)), + _ => Err(self.error( + &inner, + format!("unexpected statement {:?}", inner.as_rule()), + )), + } + } + + /// Parse alias statement. + /// Grammar: alias_stmt = { "alias" ~ ws ~ identifier ~ ws ~ ":" ~ ws ~ "=" ~ ws ~ expr ~ ws ~ ";" } + fn parse_alias_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = None; + let mut source = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + name = Some(item.as_str().to_string()); + } + Rule::expr => { + source = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + let name = name.ok_or_else(|| { + self.error_at( + location.clone().unwrap_or_default(), + "alias requires a name", + ) + })?; + let source = source.ok_or_else(|| { + self.error_at( + location.clone().unwrap_or_default(), + "alias requires a source expression", + ) + })?; + + Ok(AliasBinding { + name, + source, + location, + }) + } + + /// Parse tick statement. + /// Grammar: tick_stmt = { attribute_list? ~ ws ~ "tick" ~ ws ~ attribute_list? ~ ws ~ tick_label? ~ ws ~ tick_body } + fn parse_tick_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut label = None; + let mut attrs = Vec::new(); + let mut body = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::attribute_list => { + // Parse all attributes and add to attrs + attrs.extend(self.parse_attribute_list(item)?); + } + Rule::tick_label => { + // tick_label = { string_literal | identifier } + let label_inner = self.expect_inner(item, "tick_label")?; + label = Some(match label_inner.as_rule() { + Rule::string_literal => self.parse_string_content(label_inner.as_str()), + Rule::identifier => label_inner.as_str().to_string(), + _ => label_inner.as_str().to_string(), + }); + } + Rule::tick_body => { + // tick_body = { "{" ~ ws ~ (statement ~ ws)* ~ "}" } + for stmt_pair in item.into_inner() { + if stmt_pair.as_rule() == Rule::statement { + body.push(self.parse_statement(stmt_pair)?); + } + } + } + _ => {} + } + } + + Ok(TickStmt { + label, + attrs, + body, + location, + }) + } + + /// Parse an attribute list. + /// Grammar: attribute_list = { attribute ~ (ws ~ attribute)* } + fn parse_attribute_list(&self, pair: Pair) -> ParseResult> { + let mut attrs = Vec::new(); + for attr_pair in pair.into_inner() { + match attr_pair.as_rule() { + Rule::attribute => { + attrs.push(self.parse_attribute(attr_pair)?); + } + Rule::attrs_block => { + attrs.extend(self.parse_attrs_block(attr_pair)?); + } + _ => {} + } + } + Ok(attrs) + } + + /// Parse a single attribute. + /// Grammar: attribute = { "@attr" ~ ws ~ "(" ~ ws ~ identifier ~ ws ~ "," ~ ws ~ attr_value ~ ws ~ ")" } + fn parse_attribute(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut inner = pair.into_inner(); + + // First inner element is the identifier (attribute key) + let name_pair = inner.next().expect("attribute must have a key"); + let name = name_pair.as_str().to_string(); + + // Second element is the value + let value_pair = inner.next().expect("attribute must have a value"); + let value = Some(self.parse_attr_value(value_pair)?); + + Ok(Attribute { + name, + value, + location, + }) + } + + /// Parse an attrs block. + /// Grammar: attrs_block = { "@attrs" ~ ws ~ "(" ~ ws ~ "{" ~ ws ~ attrs_entries? ~ ws ~ "}" ~ ws ~ ")" } + fn parse_attrs_block(&self, pair: Pair) -> ParseResult> { + let location = Some(self.location(&pair)); + let mut attrs = Vec::new(); + + for item in pair.into_inner() { + if item.as_rule() == Rule::attrs_entries { + for entry in item.into_inner() { + if entry.as_rule() == Rule::attrs_entry { + let mut entry_inner = entry.into_inner(); + let name = entry_inner + .next() + .expect("attrs_entry must have key") + .as_str() + .to_string(); + let value_pair = entry_inner.next().expect("attrs_entry must have value"); + let value = Some(self.parse_attr_value(value_pair)?); + attrs.push(Attribute { + name, + value, + location: location.clone(), + }); + } + } + } + } + + Ok(attrs) + } + + /// Parse an attribute value. + /// Grammar: attr_value = { string_literal | number_literal | bool_literal | identifier } + fn parse_attr_value(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "attr_value")?; + match inner.as_rule() { + Rule::string_literal => { + let s = self.parse_string_content(inner.as_str()); + Ok(AttributeValue::String(s)) + } + Rule::number_literal => { + // Parse the number text - handle floats vs integers + let s = inner.as_str().replace('_', ""); + if s.contains('.') || s.contains('e') || s.contains('E') { + let value: f64 = s + .parse() + .map_err(|_| self.error(&inner, "invalid float literal"))?; + Ok(AttributeValue::Float(value)) + } else if s.starts_with("0x") || s.starts_with("0X") { + let value = i64::from_str_radix(&s[2..], 16) + .map_err(|_| self.error(&inner, "invalid hex literal"))?; + Ok(AttributeValue::Int(value)) + } else if s.starts_with("0b") || s.starts_with("0B") { + let value = i64::from_str_radix(&s[2..], 2) + .map_err(|_| self.error(&inner, "invalid binary literal"))?; + Ok(AttributeValue::Int(value)) + } else if s.starts_with("0o") || s.starts_with("0O") { + let value = i64::from_str_radix(&s[2..], 8) + .map_err(|_| self.error(&inner, "invalid octal literal"))?; + Ok(AttributeValue::Int(value)) + } else { + let value: i64 = s + .parse() + .map_err(|_| self.error(&inner, "invalid integer literal"))?; + Ok(AttributeValue::Int(value)) + } + } + Rule::bool_literal => { + let value = inner.as_str() == "true"; + Ok(AttributeValue::Bool(value)) + } + Rule::identifier => { + // Identifier as value (e.g., for enum-like values) + Ok(AttributeValue::Ident(inner.as_str().to_string())) + } + _ => Err(self.error(&inner, "invalid attribute value")), + } + } + + /// Parse a block. + fn parse_block(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut statements = Vec::new(); + let mut label = None; + let mut trailing_expr = None; + let mut attrs = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::attribute_list => { + attrs.extend(self.parse_attribute_list(item)?); + } + Rule::label => { + label = Some(self.parse_label(item)?); + } + Rule::statement => { + statements.push(self.parse_statement(item)?); + } + Rule::trailing_expr => { + // Trailing expression (block's return value) + let inner = self.expect_inner(item, "trailing_expr")?; + trailing_expr = Some(Box::new(self.parse_expr(inner)?)); + } + _ => {} + } + } + + Ok(Block { + label, + attrs, + statements, + trailing_expr, + location, + }) + } + + /// Parse a label. + fn parse_label(&self, pair: Pair<'a, Rule>) -> ParseResult { + let ident = self.expect_inner(pair, "label")?; + Ok(ident.as_str().to_string()) + } + + /// Parse try block statement. + /// Grammar: try_block_stmt = { try_collect_block | try_bang_block } + fn parse_try_block_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "try_block_stmt")?; + match inner.as_rule() { + Rule::try_collect_block => self.parse_try_collect_block(inner), + Rule::try_bang_block => self.parse_try_bang_block(inner), + _ => Err(self.error(&inner, "expected try or try! block")), + } + } + + /// Parse try { } block (collect all errors). + fn parse_try_collect_block(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut body = None; + let mut catch_clause = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::block => { + body = Some(self.parse_block(item)?); + } + Rule::catch_clause => { + catch_clause = Some(self.parse_catch_clause(item)?); + } + _ => {} + } + } + + Ok(TryBlockStmt { + mode: TryMode::Collect, + body: body.expect("try block must have body"), + catch_clause, + location, + }) + } + + /// Parse try! { } block (stop on first error). + fn parse_try_bang_block(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut body = None; + let mut catch_clause = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::block => { + body = Some(self.parse_block(item)?); + } + Rule::catch_clause => { + catch_clause = Some(self.parse_catch_clause(item)?); + } + _ => {} + } + } + + Ok(TryBlockStmt { + mode: TryMode::Propagate, + body: body.expect("try! block must have body"), + catch_clause, + location, + }) + } + + /// Parse catch clause: catch |err| { ... } or catch |err| expr + fn parse_catch_clause(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut capture = String::new(); + let mut body = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::identifier => { + capture = item.as_str().to_string(); + } + Rule::block => { + let parsed_block = self.parse_block(item)?; + body = Some(Expr::Block(Box::new(BlockExpr { + label: String::new(), + attrs: parsed_block.attrs, + statements: parsed_block.statements, + trailing_expr: parsed_block.trailing_expr, + location: None, + }))); + } + Rule::expr => { + body = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(CatchClause { + capture, + body: body.expect("catch clause must have body"), + location, + }) + } + + /// Parse assignment statement. + fn parse_assign_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + + let target = self.parse_expr(self.expect_next(&mut inner, &loc, "target")?)?; + let op = self.parse_assign_op(self.expect_next(&mut inner, &loc, "operator")?)?; + let value = self.parse_expr(self.expect_next(&mut inner, &loc, "value")?)?; + + Ok(AssignStmt { + target, + op, + value, + location, + }) + } + + /// Parse assignment operator. + fn parse_assign_op(&self, pair: Pair) -> ParseResult { + Ok(match pair.as_str() { + "=" => AssignOp::Assign, + "+=" => AssignOp::AddAssign, + "-=" => AssignOp::SubAssign, + "*=" => AssignOp::MulAssign, + "/=" => AssignOp::DivAssign, + "&=" => AssignOp::AndAssign, + "|=" => AssignOp::OrAssign, + "^=" => AssignOp::XorAssign, + _ => return Err(self.error(&pair, "unknown assignment operator")), + }) + } + + /// Parse if statement. + fn parse_if_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let location = Some(loc.clone()); + let mut inner = pair.into_inner(); + + // First is either if_unwrap_clause or if_condition + let first = self.expect_next(&mut inner, &loc, "if condition")?; + let (condition, capture) = match first.as_rule() { + Rule::if_unwrap_clause => { + // if value := expr { ... } (Go-style unwrapping) + let first_loc = self.location(&first); + let mut clause_inner = first.into_inner(); + let capture_name = self + .expect_next(&mut clause_inner, &first_loc, "capture name")? + .as_str() + .to_string(); + let expr = self.parse_expr(self.expect_next( + &mut clause_inner, + &first_loc, + "unwrap expression", + )?)?; + (expr, Some(capture_name)) + } + Rule::if_condition => { + // if condition { ... } + let cond_inner = self.expect_inner(first, "if_condition")?; + (self.parse_expr(cond_inner)?, None) + } + other => { + return Err(ParseError { + message: format!( + "unexpected rule {:?}, expected if_unwrap_clause or if_condition", + other + ), + location: loc, + }); + } + }; + + let mut then_body = None; + let mut else_body = None; + + for item in inner { + match item.as_rule() { + Rule::block if then_body.is_none() => { + then_body = Some(self.parse_block(item)?); + } + Rule::statement if then_body.is_none() => { + // Single statement as then body + let stmt = self.parse_statement(item)?; + then_body = Some(Block { + statements: vec![stmt], + label: None, + attrs: Vec::new(), + trailing_expr: None, + location: None, + }); + } + Rule::if_stmt => { + else_body = Some(ElseBranch::ElseIf(Box::new(self.parse_if_stmt(item)?))); + } + Rule::block => { + else_body = Some(ElseBranch::Else(self.parse_block(item)?)); + } + Rule::statement => { + // Single statement as else body + let stmt = self.parse_statement(item)?; + else_body = Some(ElseBranch::Else(Block { + statements: vec![stmt], + label: None, + attrs: Vec::new(), + trailing_expr: None, + location: None, + })); + } + _ => {} + } + } + + Ok(IfStmt { + condition, + capture, + then_body: then_body.unwrap_or_else(|| Block { + statements: vec![], + label: None, + attrs: Vec::new(), + trailing_expr: None, + location: None, + }), + else_body, + location, + }) + } + + /// Parse for statement (bounded iteration - NASA Power of 10 compliant). + fn parse_for_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut label = None; + let mut is_inline = false; + let mut range = None; + let mut captures = Vec::new(); + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::label => label = Some(self.parse_label(item)?), + Rule::for_range => range = Some(self.parse_for_range(item)?), + Rule::capture_list => captures = self.parse_capture_list(item)?, + Rule::block => body = Some(self.parse_block(item)?), + _ => { + if item.as_rule() == Rule::inline_keyword { + is_inline = true; + } + } + } + } + + Ok(ForStmt { + label, + is_inline, + range: range.expect("for needs range"), + captures, + body: body.expect("for needs body"), + location, + }) + } + + /// Parse for range. + fn parse_for_range(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "for_range")?; + + match inner.as_rule() { + Rule::range_lit => { + // range_lit = { range_start ~ ".." ~ range_end } + let loc = self.location(&inner); + let mut range_inner = inner.into_inner(); + let start_pair = self.expect_next(&mut range_inner, &loc, "range start")?; + let end_pair = self.expect_next(&mut range_inner, &loc, "range end")?; + + // range_start/range_end = { number_literal | identifier } + let start = self.parse_range_bound(start_pair)?; + let end = self.parse_range_bound(end_pair)?; + + Ok(ForRange::Range { start, end }) + } + Rule::expr => { + // Collection iteration or single expression + let first = self.parse_expr(inner)?; + Ok(ForRange::Collection(first)) + } + _ => { + // Try parsing as expression + let first = self.parse_expr(inner)?; + Ok(ForRange::Collection(first)) + } + } + } + + /// Parse range bound expression. + /// range_bound = { range_bound_term ~ (ws ~ range_bound_op ~ ws ~ range_bound_term)* } + fn parse_range_bound(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // Parse the first term + let first_term = self.expect_next(&mut inner, &location, "range bound term")?; + let mut result = self.parse_range_bound_term(first_term)?; + + // Parse remaining (op, term) pairs + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() == Rule::range_bound_op { + let op = match op_pair.as_str() { + "+" => BinaryOp::Add, + "-" => BinaryOp::Sub, + "*" => BinaryOp::Mul, + "/" => BinaryOp::Div, + _ => unreachable!(), + }; + + let term_pair = inner.next().expect("operator needs right operand"); + let right = self.parse_range_bound_term(term_pair)?; + + result = Expr::Binary(Box::new(BinaryExpr { + left: result, + op, + right, + location: Some(location.clone()), + })); + } + } + + Ok(result) + } + + /// Parse range bound term. + /// range_bound_term = { number_literal | range_bound_field_access | identifier | "(" ~ ws ~ range_bound ~ ws ~ ")" } + fn parse_range_bound_term(&self, pair: Pair) -> ParseResult { + let inner = pair + .into_inner() + .next() + .expect("range_bound_term needs content"); + match inner.as_rule() { + Rule::number_literal | Rule::identifier => self.parse_primary_expr(inner), + Rule::range_bound_field_access => self.parse_range_bound_field_access(inner), + Rule::range_bound => self.parse_range_bound(inner), + _ => unreachable!("unexpected rule in range_bound_term: {:?}", inner.as_rule()), + } + } + + /// Parse range bound field access (e.g., self.len, arr.count). + /// range_bound_field_access = { identifier ~ ("." ~ identifier)+ } + fn parse_range_bound_field_access(&self, pair: Pair) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // First identifier is the base + let first = inner.next().expect("field access needs base"); + let mut result = Expr::Ident(Ident { + name: first.as_str().to_string(), + location: Some(self.location(&first)), + }); + + // Remaining member names are field accesses + for field_pair in inner { + if field_pair.as_rule() == Rule::identifier || field_pair.as_rule() == Rule::member_name + { + result = Expr::Field(Box::new(FieldExpr { + object: result, + field: field_pair.as_str().to_string(), + location: Some(location.clone()), + })); + } + } + + Ok(result) + } + + /// Parse range expression for slicing: expr? ~ ".." ~ expr? + /// Examples: 0..2, 0.., ..2, .. + fn parse_range_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let mut start = None; + let mut end = None; + + for item in pair.into_inner() { + if item.as_rule() == Rule::expr { + // First expr is start, second is end + if start.is_none() { + start = Some(self.parse_expr(item)?); + } else { + end = Some(self.parse_expr(item)?); + } + } + } + + Ok(Expr::Range(Box::new(RangeExpr { + start, + end, + location, + }))) + } + + /// Parse capture list. + fn parse_capture_list(&self, pair: Pair) -> ParseResult> { + let mut captures = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::identifier { + captures.push(item.as_str().to_string()); + } + } + Ok(captures) + } + + /// Parse switch statement. + fn parse_switch_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + + let value = self.parse_expr(self.expect_next(&mut inner, &loc, "switch value")?)?; + let mut prongs = Vec::new(); + + for item in inner { + if item.as_rule() == Rule::switch_prong { + prongs.push(self.parse_switch_prong(item)?); + } + } + + Ok(SwitchStmt { + value, + prongs, + location, + }) + } + + /// Parse switch prong. + fn parse_switch_prong(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut cases = Vec::new(); + let mut is_else = false; + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::switch_case => cases.push(self.parse_switch_case(item)?), + Rule::expr | Rule::block => body = Some(self.parse_expr(item)?), + _ => { + if item.as_str() == "else" { + is_else = true; + } + } + } + } + + Ok(SwitchProng { + cases, + is_else, + body: body.expect("prong needs body"), + location, + }) + } + + /// Parse switch case. + fn parse_switch_case(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + + let value = self.parse_expr(self.expect_next(&mut inner, &loc, "case value")?)?; + let end = inner.next().map(|p| self.parse_expr(p)).transpose()?; + + Ok(SwitchCase { + value, + end, + location, + }) + } + + /// Parse return statement. + fn parse_return_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let value = pair + .into_inner() + .next() + .map(|p| self.parse_expr(p)) + .transpose()?; + + Ok(ReturnStmt { value, location }) + } + + /// Parse break statement. + fn parse_break_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut label = None; + let mut value = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::identifier => label = Some(item.as_str().to_string()), + Rule::expr => value = Some(self.parse_expr(item)?), + _ => {} + } + } + + Ok(BreakStmt { + label, + value, + location, + }) + } + + /// Parse continue statement. + fn parse_continue_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let label = pair + .into_inner() + .find(|p| p.as_rule() == Rule::identifier) + .map(|p| p.as_str().to_string()); + + Ok(ContinueStmt { label, location }) + } + + /// Parse defer statement. + fn parse_defer_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = self.expect_inner(pair, "defer_stmt")?; + let body = Box::new(self.parse_statement(inner)?); + + Ok(DeferStmt { body, location }) + } + + /// Parse errdefer statement. + /// Grammar: errdefer_stmt = { "errdefer" ~ ws ~ ("|" ~ ws ~ identifier ~ ws ~ "|" ~ ws)? ~ (block | statement) } + fn parse_errdefer_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut capture = None; + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + // Capture variable name + capture = Some(item.as_str().to_string()); + } + Rule::block | Rule::statement => { + // Body of errdefer + body = Some(Box::new(self.parse_statement(item)?)); + } + _ => { + // Must be block or statement (fallback) + body = Some(Box::new(self.parse_statement(item)?)); + } + } + } + + let body = body.ok_or_else(|| { + self.error_at( + location.clone().unwrap_or_default(), + "errdefer requires a body", + ) + })?; + + Ok(ErrDeferStmt { + body, + capture, + location, + }) + } + + /// Parse expression statement. + /// Grammar: expr_stmt = { attribute_list? ~ ws ~ expr ~ ws ~ ";" } + fn parse_expr_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut attrs = Vec::new(); + let mut expr = None; + + for item in inner { + match item.as_rule() { + Rule::attribute_list => { + attrs = self.parse_attribute_list(item)?; + } + Rule::expr => { + expr = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(ExprStmt { + expr: expr.expect("expression statement must have an expression"), + attrs, + location, + }) + } + + // ========================================================================= + // Expressions + // ========================================================================= + + /// Parse an expression (entry point). + fn parse_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "expr")?; + + match inner.as_rule() { + Rule::comptime_expr => self.parse_comptime_expr(inner), + Rule::runtime_expr => self.parse_runtime_expr(inner), + Rule::or_expr => self.parse_or_expr(inner), + Rule::primary_expr => self.parse_primary_expr(inner), + _ => self.parse_primary_expr(inner), + } + } + + /// Parse comptime expression. + /// Grammar: comptime_expr = { "comptime" ~ ws ~ (block | runtime_expr) } + fn parse_comptime_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = self.expect_inner(pair, "comptime_expr")?; + + // The inner can be either a block or a runtime_expr + let expr = match inner.as_rule() { + Rule::block => { + // Convert the block to a BlockExpr + let block = self.parse_block(inner)?; + Expr::Block(Box::new(BlockExpr { + label: block.label.unwrap_or_default(), + attrs: block.attrs, + statements: block.statements, + trailing_expr: block.trailing_expr, + location: block.location, + })) + } + _ => self.parse_expr(inner)?, + }; + + Ok(Expr::Comptime(Box::new(ComptimeExpr { + inner: expr, + location, + }))) + } + + /// Parse runtime expression (binary operators). + fn parse_runtime_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "runtime_expr")?; + self.parse_or_expr(inner) + } + + /// Parse or expression. + /// Grammar: or_expr = { catch_expr ~ (or_kw ~ catch_expr)* } + fn parse_or_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = + self.parse_catch_expr(self.expect_next(&mut inner, &loc, "or operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip the or_kw operator rule + let right_pair = if next_pair.as_rule() == Rule::or_kw { + self.expect_next(&mut inner, &loc, "or right operand")? + } else { + next_pair + }; + let right = self.parse_catch_expr(right_pair)?; + left = Expr::Binary(Box::new(BinaryExpr { + op: BinaryOp::Or, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse catch expression. + /// Grammar: catch_expr = { orelse_expr ~ (catch_kw ~ ("|" ~ ws ~ identifier ~ ws ~ "|" ~ ws)? ~ orelse_expr)* } + fn parse_catch_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + let mut left = + self.parse_orelse_expr(self.expect_next(&mut inner, &loc, "catch operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip the catch_kw operator rule if present + let next_pair = if next_pair.as_rule() == Rule::catch_kw { + self.expect_next(&mut inner, &loc, "catch handler")? + } else { + next_pair + }; + + // Check if this is an identifier (error capture) or an orelse_expr (handler) + if next_pair.as_rule() == Rule::identifier { + // This is the capture variable: catch |err| handler + let capture = Some(next_pair.as_str().to_string()); + let handler = self.parse_orelse_expr(self.expect_next( + &mut inner, + &loc, + "catch handler body", + )?)?; + left = Expr::Catch(Box::new(CatchExpr { + operand: left, + capture, + handler, + location: location.clone(), + })); + } else { + // No capture: catch handler + let handler = self.parse_orelse_expr(next_pair)?; + left = Expr::Catch(Box::new(CatchExpr { + operand: left, + capture: None, + handler, + location: location.clone(), + })); + } + } + + Ok(left) + } + + /// Parse orelse expression. + /// Grammar: orelse_expr = { and_expr ~ (orelse_kw ~ and_expr)* } + fn parse_orelse_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = + self.parse_and_expr(self.expect_next(&mut inner, &loc, "orelse operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip the orelse_kw operator rule + let right_pair = if next_pair.as_rule() == Rule::orelse_kw { + self.expect_next(&mut inner, &loc, "orelse right operand")? + } else { + next_pair + }; + let right = self.parse_and_expr(right_pair)?; + left = Expr::Binary(Box::new(BinaryExpr { + op: BinaryOp::Orelse, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse and expression. + /// Grammar: and_expr = { cmp_expr ~ (and_kw ~ cmp_expr)* } + fn parse_and_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = self.parse_cmp_expr(self.expect_next(&mut inner, &loc, "and operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip the and_kw operator rule + let right_pair = if next_pair.as_rule() == Rule::and_kw { + self.expect_next(&mut inner, &loc, "and right operand")? + } else { + next_pair + }; + let right = self.parse_cmp_expr(right_pair)?; + left = Expr::Binary(Box::new(BinaryExpr { + op: BinaryOp::And, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse binary chain with given operator. + fn parse_binary_chain(&self, pair: Pair<'a, Rule>, op: BinaryOp) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "binary operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip operator rules (symbol operators) + let right_pair = if matches!( + next_pair.as_rule(), + Rule::bitor_op | Rule::bitxor_op | Rule::bitand_op + ) { + self.expect_next(&mut inner, &loc, "binary right operand")? + } else { + next_pair + }; + let right = self.parse_next_precedence(right_pair)?; + left = Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse next precedence level. + fn parse_next_precedence(&self, pair: Pair<'a, Rule>) -> ParseResult { + match pair.as_rule() { + Rule::catch_expr => self.parse_catch_expr(pair), + Rule::orelse_expr => self.parse_orelse_expr(pair), + Rule::and_expr => self.parse_and_expr(pair), + Rule::cmp_expr => self.parse_cmp_expr(pair), + Rule::bitwise_or_expr => self.parse_binary_chain(pair, BinaryOp::BitOr), + Rule::bitwise_xor_expr => self.parse_binary_chain(pair, BinaryOp::BitXor), + Rule::bitwise_and_expr => self.parse_binary_chain(pair, BinaryOp::BitAnd), + Rule::shift_expr => self.parse_shift_expr(pair), + Rule::add_expr => self.parse_add_expr(pair), + Rule::suffixed_expr => self.parse_suffixed_expr(pair), + Rule::mul_expr => self.parse_mul_expr(pair), + Rule::unary_expr => self.parse_unary_expr(pair), + Rule::postfix_expr => self.parse_postfix_expr(pair), + Rule::primary_expr => self.parse_primary_expr(pair), + _ => self.parse_primary_expr(pair), + } + } + + /// Parse comparison expression. + fn parse_cmp_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "cmp operand")?)?; + + if let Some(op_pair) = inner.next() { + let op = self.parse_cmp_op(op_pair)?; + let right = self.parse_next_precedence(self.expect_next( + &mut inner, + &loc, + "cmp right operand", + )?)?; + Ok(Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + }))) + } else { + Ok(left) + } + } + + /// Parse comparison operator. + fn parse_cmp_op(&self, pair: Pair) -> ParseResult { + // Check for sub-rules first (in_op, not_in_op) + if let Some(inner) = pair.clone().into_inner().next() { + return match inner.as_rule() { + Rule::in_op => Ok(BinaryOp::In), + Rule::not_in_op => Ok(BinaryOp::NotIn), + _ => Err(self.error(&pair, "unknown comparison operator")), + }; + } + // Direct string match for simple operators + Ok(match pair.as_str() { + "==" => BinaryOp::Eq, + "!=" => BinaryOp::Ne, + "<" => BinaryOp::Lt, + "<=" => BinaryOp::Le, + ">" => BinaryOp::Gt, + ">=" => BinaryOp::Ge, + "in" => BinaryOp::In, + _ => return Err(self.error(&pair, "unknown comparison operator")), + }) + } + + /// Parse shift expression. + fn parse_shift_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "shift operand")?)?; + + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() != Rule::shift_op { + continue; + } + let op = match op_pair.as_str() { + "<<" => BinaryOp::Shl, + ">>" => BinaryOp::Shr, + _ => continue, + }; + let right = self.parse_next_precedence(self.expect_next( + &mut inner, + &loc, + "shift right operand", + )?)?; + left = Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse additive expression. + fn parse_add_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "add operand")?)?; + + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() != Rule::add_op { + continue; + } + let op = match op_pair.as_str() { + "+" => BinaryOp::Add, + "-" => BinaryOp::Sub, + _ => continue, + }; + let right = self.parse_next_precedence(self.expect_next( + &mut inner, + &loc, + "add right operand", + )?)?; + left = Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse multiplicative expression. + fn parse_mul_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "mul operand")?)?; + + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() != Rule::mul_op { + continue; + } + let op = match op_pair.as_str() { + "*" => BinaryOp::Mul, + "/" => BinaryOp::Div, + "%" => BinaryOp::Mod, + _ => continue, + }; + let right = self.parse_next_precedence(self.expect_next( + &mut inner, + &loc, + "mul right operand", + )?)?; + left = Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse suffixed expression: `mul_expr ~ (expr_suffix)?` + /// Handles both angle units (`0.25 turns`, `pi/4 rad`) and type suffixes (`42 u32`, `1/4 f64`). + fn parse_suffixed_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // Parse the expression part + let value = self.parse_next_precedence(self.expect_next( + &mut inner, + &location, + "expression in suffixed expr", + )?)?; + + // Check for optional suffix (angle unit or type) + if let Some(suffix_pair) = inner.next() + && suffix_pair.as_rule() == Rule::expr_suffix + { + // Get the inner rule (angle_unit or type_suffix) + let inner_suffix = self.expect_inner(suffix_pair, "expr_suffix")?; + match inner_suffix.as_rule() { + Rule::angle_unit => { + let unit = match inner_suffix.as_str() { + "turns" => AngleUnit::Turns, + "rad" => AngleUnit::Rad, + _ => return Err(self.error(&inner_suffix, "unknown angle unit")), + }; + return Ok(Expr::AngleLit(Box::new(AngleLit { + value, + unit, + location: Some(location), + }))); + } + Rule::type_ascription_suffix => { + // Get the actual type keyword + let type_inner = self.expect_inner(inner_suffix, "type_ascription_suffix")?; + let type_name = type_inner.as_str().to_string(); + return Ok(Expr::TypeAscription(Box::new(TypeAscription { + value, + type_name, + location: Some(location), + }))); + } + _ => {} + } + } + + // No suffix - return the value as-is + Ok(value) + } + + /// Parse unary expression. + fn parse_unary_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner().peekable(); + let mut ops = Vec::new(); + + // Collect unary operators + while let Some(item) = inner.peek() { + if item.as_rule() == Rule::unary_op { + // Safe: we just peeked and confirmed it exists + if let Some(op) = inner.next() { + ops.push(op); + } + } else { + break; + } + } + + // Parse the operand + let operand = self.expect_next(&mut inner, &location, "operand in unary expression")?; + let mut expr = self.parse_postfix_expr(operand)?; + + // Apply operators in reverse order + for op_pair in ops.into_iter().rev() { + let op = match op_pair.as_str() { + "try" => UnaryOp::Try, + "-" => UnaryOp::Neg, + "!" => UnaryOp::Not, + "~" => UnaryOp::BitNot, + "&" => UnaryOp::AddrOf, + "*" => UnaryOp::Deref, + _ => continue, + }; + expr = Expr::Unary(Box::new(UnaryExpr { + op, + operand: expr, + location: Some(self.location(&op_pair)), + })); + } + + Ok(expr) + } + + /// Parse postfix expression. + fn parse_postfix_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + let primary = self.expect_next(&mut inner, &location, "primary expression")?; + let mut expr = self.parse_primary_expr(primary)?; + + // Apply postfix operators + for op in inner { + // Capture end location from the operator + let op_span = op.as_span(); + let (op_end_line, op_end_col) = op_span.end_pos().line_col(); + + // Get start location from current expression, or use operator's start + let expr_start = expr.get_location(); + let (start_line, start_col) = expr_start + .as_ref() + .map(|loc| (loc.line, loc.column)) + .unwrap_or_else(|| { + let (l, c) = op.line_col(); + (l as u32, c as u32) + }); + + // Combined location spans from expression start to operator end + let combined_location = SourceLocation { + line: start_line, + column: start_col, + end_line: op_end_line as u32, + end_column: op_end_col as u32, + file: self.file.clone(), + }; + + // postfix_op is a wrapper around call | field_access | index_access | optional_unwrap | error_unwrap + // We need to get the inner rule + let actual_op = if op.as_rule() == Rule::postfix_op { + self.expect_inner(op, "postfix_op")? + } else { + op + }; + match actual_op.as_rule() { + Rule::call => { + let args = self.parse_arg_list(actual_op)?; + expr = Expr::Call(Box::new(CallExpr { + callee: expr, + args, + location: Some(combined_location), + })); + } + Rule::batch_apply => { + // batch_apply = { ws ~ "{" ~ ws ~ batch_elements? ~ ws ~ "}" } + let mut targets = Vec::new(); + for inner in actual_op.into_inner() { + if inner.as_rule() == Rule::batch_elements { + for elem in inner.into_inner() { + targets.push(self.parse_expr(elem)?); + } + } + } + expr = Expr::BatchApply(Box::new(BatchApplyExpr { + operation: expr, + targets, + location: Some(combined_location), + })); + } + Rule::field_access => { + let field = self + .expect_inner(actual_op, "field_access")? + .as_str() + .to_string(); + expr = Expr::Field(Box::new(FieldExpr { + object: expr, + field, + location: Some(combined_location), + })); + } + Rule::index_access => { + let index_inner = self.expect_inner(actual_op, "index_access")?; + // Check if the index is a range expression (for slicing) + let index = if index_inner.as_rule() == Rule::range_expr { + self.parse_range_expr(index_inner)? + } else { + self.parse_expr(index_inner)? + }; + expr = Expr::Index(Box::new(IndexExpr { + object: expr, + index, + location: Some(combined_location), + })); + } + Rule::optional_unwrap => { + expr = Expr::Unary(Box::new(UnaryExpr { + op: UnaryOp::OptionalUnwrap, + operand: expr, + location: Some(combined_location), + })); + } + Rule::error_unwrap => { + expr = Expr::Unary(Box::new(UnaryExpr { + op: UnaryOp::ErrorUnwrap, + operand: expr, + location: Some(combined_location), + })); + } + _ => {} + } + } + + Ok(expr) + } + + /// Parse argument list. + fn parse_arg_list(&self, pair: Pair) -> ParseResult> { + let mut args = Vec::new(); + for item in pair.into_inner() { + match item.as_rule() { + Rule::arg_list => { + for arg in item.into_inner() { + if arg.as_rule() == Rule::expr { + args.push(self.parse_expr(arg)?); + } + } + } + Rule::expr => { + args.push(self.parse_expr(item)?); + } + _ => {} + } + } + Ok(args) + } + + /// Parse primary expression. + fn parse_primary_expr(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + + match pair.as_rule() { + Rule::number_literal => self.parse_number(pair), + Rule::string_literal => Ok(Expr::StringLit(StringLit { + value: self.parse_string_content(pair.as_str()), + location, + })), + Rule::raw_string => { + // Raw string: r"..." - no escape processing + let s = pair.as_str(); + // Remove r" prefix and " suffix + let value = s[2..s.len() - 1].to_string(); + Ok(Expr::StringLit(StringLit { value, location })) + } + Rule::multiline_string => { + // Multi-line string: """...""" - preserves newlines + let s = pair.as_str(); + // Remove """ prefix and """ suffix (3 chars each) + let content = &s[3..s.len() - 3]; + // Process escape sequences within the content + let value = self.unescape(content); + Ok(Expr::StringLit(StringLit { value, location })) + } + Rule::f_string => self.parse_f_string(pair), + Rule::char_literal => { + let s = pair.as_str(); + let c = self.parse_char_content(s); + Ok(Expr::CharLit(CharLit { value: c, location })) + } + Rule::bool_literal => Ok(Expr::BoolLit(BoolLit { + value: pair.as_str() == "true", + location, + })), + Rule::none_literal => Ok(Expr::Null(NullLit { location })), + Rule::undefined_literal => Ok(Expr::Undefined(UndefinedLit { location })), + Rule::unit_literal => Ok(Expr::Unit(UnitLit { location })), + Rule::identifier => Ok(Expr::Ident(Ident { + name: pair.as_str().to_string(), + location, + })), + Rule::builtin_call => self.parse_builtin_call(pair), + Rule::struct_init => self.parse_struct_init(pair), + Rule::array_init => self.parse_array_init(pair), + Rule::if_expr => self.parse_if_expr(pair), + Rule::try_block_expr => self.parse_try_block_expr(pair), + Rule::block_expr => self.parse_block_expr(pair), + Rule::paren_or_tuple => { + // paren_or_tuple = { "(" ~ ws ~ expr ~ (ws ~ "," ~ ws ~ expr)* ~ (ws ~ ",")? ~ ws ~ ")" } + // Single element without trailing comma -> parenthesized expr (unwrap) + // Multiple elements or trailing comma -> tuple + let pair_str = pair.as_str(); + let has_trailing_comma = pair_str.trim_end_matches(')').trim_end().ends_with(','); + + let mut elements = Vec::new(); + for inner in pair.into_inner() { + elements.push(self.parse_expr(inner)?); + } + + if elements.len() == 1 && !has_trailing_comma { + // Single element, no trailing comma -> parenthesized expression + Ok(elements.pop().unwrap()) + } else { + // Multiple elements or trailing comma -> tuple + Ok(Expr::Tuple(Box::new(TupleExpr { elements, location }))) + } + } + Rule::bracket_array => { + // bracket_array = { "[" ~ ws ~ bracket_array_elements? ~ ws ~ "]" } + let mut elements = Vec::new(); + for inner in pair.into_inner() { + if inner.as_rule() == Rule::bracket_array_elements { + for elem in inner.into_inner() { + elements.push(self.parse_expr(elem)?); + } + } + } + Ok(Expr::BracketArray(Box::new(BracketArrayExpr { + elements, + location, + }))) + } + Rule::set_literal => { + // set_literal = { "set" ~ ws ~ "{" ~ ws ~ set_elements? ~ ws ~ "}" } + let mut elements = Vec::new(); + for inner in pair.into_inner() { + if inner.as_rule() == Rule::set_elements { + for elem in inner.into_inner() { + elements.push(self.parse_expr(elem)?); + } + } + } + Ok(Expr::Set(Box::new(SetExpr { + elements, + element_type: None, + location, + }))) + } + Rule::measure_expr => { + // measure_expr = { "mz" ~ ws ~ "(" ~ ws ~ pack_modifier? ~ type_expr ~ ws ~ ")" ~ ws ~ measure_target } + let mut result_type = None; + let mut targets = None; + let mut pack = false; + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::pack_modifier => { + pack = true; + } + Rule::type_expr => { + result_type = Some(self.parse_type_expr(inner)?); + } + Rule::measure_target => { + // measure_target = { bracket_array | postfix_expr } + let target_inner = self.expect_inner(inner, "measure_target")?; + // Handle each rule type directly + targets = Some(match target_inner.as_rule() { + Rule::bracket_array => self.parse_primary_expr(target_inner)?, + Rule::postfix_expr => self.parse_postfix_expr(target_inner)?, + _ => self.parse_primary_expr(target_inner)?, + }); + } + _ => {} + } + } + Ok(Expr::Measure(Box::new(MeasureExpr { + result_type: result_type.expect("measure requires type"), + pack, + targets: targets.expect("measure requires targets"), + location, + }))) + } + Rule::channel_expr => { + // channel_expr = { emit_prefix ~ ws ~ channel_name ~ ws ~ "." ~ ws ~ channel_command } + // emit_prefix = { "@emit" ~ ws ~ "." } + // channel_command = { channel_command_name ~ ws ~ "(" ~ ws ~ channel_args? ~ ws ~ ")" } + let mut channel = None; + let mut command = None; + let mut args = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::emit_prefix => { + // Just consume the @emit. prefix + } + Rule::channel_name => { + channel = Some(item.as_str().to_string()); + } + Rule::channel_command => { + for cmd_item in item.into_inner() { + match cmd_item.as_rule() { + Rule::channel_command_name => { + command = Some(cmd_item.as_str().to_string()); + } + Rule::channel_args => { + for arg_item in cmd_item.into_inner() { + if arg_item.as_rule() == Rule::channel_arg { + args.push(self.parse_channel_arg(arg_item)?); + } + } + } + _ => {} + } + } + } + _ => {} + } + } + + Ok(Expr::Channel(Box::new(ChannelExpr { + channel: channel.expect("channel_expr requires channel name"), + command: command.expect("channel_expr requires command"), + args, + location, + }))) + } + Rule::result_expr => { + // result_expr = { "result" ~ ws ~ "(" ~ ws ~ string_literal ~ ws ~ "," ~ ws ~ expr ~ ws ~ ")" } + let mut tag = None; + let mut value = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::string_literal => { + tag = Some(self.parse_string_content(item.as_str())); + } + Rule::expr => { + value = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(Expr::Result(Box::new(ResultExpr { + tag: tag.expect("result requires tag"), + value: value.expect("result requires value"), + location, + }))) + } + Rule::gate_expr => { + // gate_expr = { param_gate_expr | simple_gate_expr } + let inner = self.expect_inner(pair, "gate_expr")?; + self.parse_gate_expr_inner(inner, location) + } + Rule::param_gate_expr | Rule::simple_gate_expr => { + self.parse_gate_expr_inner(pair, location) + } + Rule::struct_literal => { + // struct_literal = { ("packed" ~ ws)? ~ "struct" ~ ws ~ "{" ~ ws ~ struct_body ~ ws ~ "}" } + // Anonymous struct type definition: struct { x: i32, y: i32 } + let mut is_packed = false; + let mut fields = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::packed_keyword => { + is_packed = true; + } + Rule::struct_body => { + // Parse struct fields (ignoring methods and bindings for anonymous structs) + for body_item in item.into_inner() { + if body_item.as_rule() == Rule::struct_field { + fields.push(self.parse_struct_field(body_item)?); + } + } + } + _ => {} + } + } + + Ok(Expr::AnonStruct(Box::new(AnonStructExpr { + fields, + is_packed, + location, + }))) + } + Rule::enum_literal => { + // enum_literal = similar to struct_literal + Ok(Expr::StructInit(Box::new(StructInitExpr { + ty: None, + fields: Vec::new(), + location, + }))) + } + Rule::fn_literal => { + // Anonymous function: fn(params) -> return_type { body } + let inner = pair.into_inner(); + let mut params = Vec::new(); + let mut return_type = None; + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::param_list => { + params = self.parse_param_list(item)?; + } + Rule::return_type => { + return_type = Some(self.parse_return_type(item)?); + } + Rule::block => { + body = Some(self.parse_block(item)?); + } + _ => {} + } + } + + Ok(Expr::FnLit(Box::new(FnDecl { + name: "".to_string(), + params, + return_type, + body: body.expect("function literal must have body"), + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location, + }))) + } + Rule::self_expr => Ok(Expr::Ident(Ident { + name: "Self".to_string(), + location, + })), + Rule::error_value => { + // error_value = { "error" ~ "." ~ identifier } + let inner = self.expect_inner(pair, "error_value")?; + let name = inner.as_str().to_string(); + Ok(Expr::ErrorValue(Box::new(ErrorValueExpr { + name, + location, + }))) + } + Rule::fault_value => { + // fault_value = { "fault" ~ "." ~ identifier } + let inner = self.expect_inner(pair, "fault_value")?; + let name = inner.as_str().to_string(); + Ok(Expr::FaultValue(Box::new(FaultValueExpr { + name, + location, + }))) + } + Rule::array_type_expr => { + // array_type_expr = { "[" ~ ws ~ (array_size | "_")? ~ ... ~ "]" ~ ws ~ type_identifier } + // This represents a type as a value (like [N]T) + Ok(Expr::Ident(Ident { + name: pair.as_str().to_string(), + location, + })) + } + Rule::primary_expr => { + let inner = self.expect_inner(pair, "primary_expr")?; + self.parse_primary_expr(inner) + } + Rule::atom => { + // atom is a compound-atomic wrapper around leaf expressions + let inner = self.expect_inner(pair, "atom")?; + self.parse_primary_expr(inner) + } + _ => Err(self.error(&pair, format!("unexpected primary {:?}", pair.as_rule()))), + } + } + + /// Parse number literal with optional type suffix. + fn parse_number(&self, pair: Pair) -> ParseResult { + let loc = self.location(&pair); + let raw = pair.as_str(); + + // Extract type suffix if present + let (num_str, suffix) = self.extract_number_suffix(raw); + let s = num_str.replace('_', ""); + + // Check for float (must check before extracting suffix changes things) + let is_float = s.contains('.') + || (s.contains('e') || s.contains('E')) && !s.starts_with("0x") && !s.starts_with("0X"); + + if is_float { + let value: f64 = s + .parse() + .map_err(|_| self.error_at(loc.clone(), "invalid float literal"))?; + Ok(Expr::FloatLit(FloatLit { + value, + suffix, + location: Some(loc), + })) + } else if s.starts_with("0x") || s.starts_with("0X") { + let value = i128::from_str_radix(&s[2..], 16) + .map_err(|_| self.error_at(loc.clone(), "invalid hex literal"))?; + Ok(Expr::IntLit(IntLit { + value, + suffix, + location: Some(loc), + })) + } else if s.starts_with("0b") || s.starts_with("0B") { + let value = i128::from_str_radix(&s[2..], 2) + .map_err(|_| self.error_at(loc.clone(), "invalid binary literal"))?; + Ok(Expr::IntLit(IntLit { + value, + suffix, + location: Some(loc), + })) + } else if s.starts_with("0o") || s.starts_with("0O") { + let value = i128::from_str_radix(&s[2..], 8) + .map_err(|_| self.error_at(loc.clone(), "invalid octal literal"))?; + Ok(Expr::IntLit(IntLit { + value, + suffix, + location: Some(loc), + })) + } else { + let value: i128 = s + .parse() + .map_err(|_| self.error_at(loc.clone(), "invalid integer literal"))?; + Ok(Expr::IntLit(IntLit { + value, + suffix, + location: Some(loc), + })) + } + } + + /// Extract type suffix from a number literal string. + /// Returns (number_part, optional_suffix). + fn extract_number_suffix<'b>(&self, s: &'b str) -> (&'b str, Option) { + // Integer suffixes (check longer ones first) + const INT_SUFFIXES: &[&str] = &[ + "u128", "i128", "usize", "isize", "u64", "i64", "u32", "i32", "u16", "i16", "u8", "i8", + "u1", "i1", + ]; + // Float suffixes + const FLOAT_SUFFIXES: &[&str] = &["f128", "f64", "f32", "f16", "a64"]; + + // Check for suffix with optional underscore separator + for &suffix in INT_SUFFIXES.iter().chain(FLOAT_SUFFIXES.iter()) { + // Check for _suffix pattern + let with_underscore = format!("_{}", suffix); + if s.ends_with(&with_underscore) { + return ( + &s[..s.len() - with_underscore.len()], + Some(suffix.to_string()), + ); + } + // Check for direct suffix (no underscore) + if let Some(prefix) = s.strip_suffix(suffix) { + // Make sure we're not matching part of a hex digit + if !prefix.is_empty() + && (prefix.ends_with(|c: char| c.is_ascii_digit()) || prefix.ends_with('_')) + { + return (prefix, Some(suffix.to_string())); + } + } + } + + (s, None) + } + + /// Parse string content (remove quotes and handle escapes). + fn parse_string_content(&self, s: &str) -> String { + let s = &s[1..s.len() - 1]; // Remove quotes + self.unescape(s) + } + + /// Parse char content. + fn parse_char_content(&self, s: &str) -> char { + let s = &s[1..s.len() - 1]; // Remove quotes + let unescaped = self.unescape(s); + unescaped.chars().next().unwrap_or('\0') + } + + /// Parse f-string (Python-style interpolated string): f"Hello {name}!" + /// Supports format specifiers: f"{x:.2f}", f"{name:>10}" + fn parse_f_string(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut parts = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::f_string_part => { + let inner = self.expect_inner(item, "f_string_part")?; + match inner.as_rule() { + Rule::f_string_text => { + let text = self.unescape(inner.as_str()); + parts.push(FStringPart::Text(text)); + } + Rule::f_string_interp => { + let (expr, format) = self.parse_f_string_interp(inner)?; + parts.push(FStringPart::Expr { expr, format }); + } + _ => {} + } + } + Rule::f_string_text => { + let text = self.unescape(item.as_str()); + parts.push(FStringPart::Text(text)); + } + Rule::f_string_interp => { + let (expr, format) = self.parse_f_string_interp(item)?; + parts.push(FStringPart::Expr { expr, format }); + } + _ => {} + } + } + + Ok(Expr::FString(Box::new(FStringExpr { parts, location }))) + } + + /// Parse f-string interpolation: {expr} or {expr:format} + fn parse_f_string_interp(&self, pair: Pair<'a, Rule>) -> ParseResult<(Expr, Option)> { + let location = self.location(&pair); + let mut expr = None; + let mut format = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::expr => { + expr = Some(self.parse_expr(inner)?); + } + Rule::f_string_format => { + // f_string_format = { ":" ~ f_string_format_spec } + for fmt_inner in inner.into_inner() { + if fmt_inner.as_rule() == Rule::f_string_format_spec { + let spec = fmt_inner.as_str().to_string(); + if !spec.is_empty() { + format = Some(spec); + } + } + } + } + _ => {} + } + } + + let expr = expr.ok_or_else(|| { + self.error_at(location, "expected expression in f-string interpolation") + })?; + Ok((expr, format)) + } + + /// Unescape string content. + fn unescape(&self, s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') => result.push('\n'), + Some('r') => result.push('\r'), + Some('t') => result.push('\t'), + Some('\\') => result.push('\\'), + Some('"') => result.push('"'), + Some('\'') => result.push('\''), + Some('0') => result.push('\0'), + Some('{') => result.push('{'), + Some('}') => result.push('}'), + Some('x') => { + let hex: String = chars.by_ref().take(2).collect(); + if let Ok(n) = u8::from_str_radix(&hex, 16) { + result.push(n as char); + } + } + Some(other) => { + result.push('\\'); + result.push(other); + } + None => result.push('\\'), + } + } else { + result.push(c); + } + } + + result + } + + /// Parse builtin call (@import, @This, etc.). + fn parse_builtin_call(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + + let name = self + .expect_next(&mut inner, &loc, "builtin name")? + .as_str() + .to_string(); + let location = Some(loc); + let args = if let Some(arg_list) = inner.next() { + self.parse_arg_list(arg_list)? + } else { + Vec::new() + }; + + Ok(Expr::Builtin(Box::new(BuiltinExpr { + name, + args, + location, + }))) + } + + /// Parse struct initialization. + fn parse_struct_init(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let location = Some(loc.clone()); + let inner_pair = self.expect_inner(pair, "struct_init")?; + + // struct_init contains either typed_struct_init or anon_struct_init + match inner_pair.as_rule() { + Rule::typed_struct_init => self.parse_typed_struct_init(inner_pair, location), + Rule::anon_struct_init => self.parse_anon_struct_init(inner_pair, location), + other => Err(ParseError { + message: format!( + "unexpected rule {:?}, expected typed_struct_init or anon_struct_init", + other + ), + location: loc, + }), + } + } + + fn parse_typed_struct_init( + &self, + pair: Pair, + location: Option, + ) -> ParseResult { + let mut inner = pair.into_inner(); + + // First element is type_identifier + let ty = if let Some(first) = inner.next() { + if first.as_rule() == Rule::type_identifier { + Some(self.parse_type_identifier(first)?) + } else { + None + } + } else { + None + }; + + let fields = self.collect_field_inits(inner)?; + + Ok(Expr::StructInit(Box::new(StructInitExpr { + ty, + fields, + location, + }))) + } + + fn parse_anon_struct_init( + &self, + pair: Pair, + location: Option, + ) -> ParseResult { + let inner = pair.into_inner(); + let fields = self.collect_field_inits(inner)?; + + Ok(Expr::StructInit(Box::new(StructInitExpr { + ty: None, + fields, + location, + }))) + } + + fn collect_field_inits(&self, inner: Pairs) -> ParseResult> { + let mut fields = Vec::new(); + for item in inner { + match item.as_rule() { + Rule::field_init => { + fields.push(self.parse_field_init(item)?); + } + Rule::field_init_list => { + for field in item.into_inner() { + if field.as_rule() == Rule::field_init { + fields.push(self.parse_field_init(field)?); + } + } + } + _ => {} + } + } + Ok(fields) + } + + /// Parse field initializer. + fn parse_field_init(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + + let name = self + .expect_next(&mut inner, &loc, "field name")? + .as_str() + .to_string(); + let location = Some(loc); + + // Rust-style: `field: value` or shorthand `field` (when var name matches) + let value = if let Some(expr_pair) = inner.next() { + self.parse_expr(expr_pair)? + } else { + // Shorthand: `field` expands to `field: field` + Expr::Ident(Ident { + name: name.clone(), + location: location.clone(), + }) + }; + + Ok(FieldInit { + name, + value, + location, + }) + } + + /// Parse array initialization. + fn parse_array_init(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let mut inner = pair.into_inner().peekable(); + + let ty = if let Some(first) = inner.peek() { + if first.as_rule() == Rule::type_expr { + // Safe: we just peeked and confirmed it exists + if let Some(type_pair) = inner.next() { + Some(self.parse_type_expr(type_pair)?) + } else { + None + } + } else { + None + } + } else { + None + }; + + let mut elements = Vec::new(); + for item in inner { + if item.as_rule() == Rule::expr { + elements.push(self.parse_expr(item)?); + } + } + + Ok(Expr::ArrayInit(Box::new(ArrayInitExpr { + ty, + elements, + location, + }))) + } + + /// Parse try block expression. + /// Grammar: try_block_expr = { try_collect_expr | try_bang_expr } + fn parse_try_block_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "try_block_expr")?; + let location = Some(self.location(&inner)); + + match inner.as_rule() { + Rule::try_collect_expr => { + let mut body = None; + let mut catch_clause = None; + for item in inner.into_inner() { + match item.as_rule() { + Rule::block => body = Some(self.parse_block(item)?), + Rule::catch_clause => catch_clause = Some(self.parse_catch_clause(item)?), + _ => {} + } + } + Ok(Expr::TryBlock(Box::new(TryBlockExpr { + mode: TryMode::Collect, + body: body.expect("try block must have body"), + catch_clause, + location, + }))) + } + Rule::try_bang_expr => { + let mut body = None; + let mut catch_clause = None; + for item in inner.into_inner() { + match item.as_rule() { + Rule::block => body = Some(self.parse_block(item)?), + Rule::catch_clause => catch_clause = Some(self.parse_catch_clause(item)?), + _ => {} + } + } + Ok(Expr::TryBlock(Box::new(TryBlockExpr { + mode: TryMode::Propagate, + body: body.expect("try! block must have body"), + catch_clause, + location, + }))) + } + _ => Err(self.error(&inner, "expected try or try! expression")), + } + } + + /// Parse if expression. + fn parse_if_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + + // First is the condition (in parentheses) + let cond_pair = self.expect_next(&mut inner, &loc, "if condition")?; + let condition = self.parse_expr(cond_pair)?; + + // Then block for the "then" branch + let then_block = self.expect_next(&mut inner, &loc, "then block")?; + let then_parsed = self.parse_block(then_block)?; + let then_expr = Expr::Block(Box::new(BlockExpr { + label: then_parsed.label.clone().unwrap_or_default(), + attrs: then_parsed.attrs, + statements: then_parsed.statements, + trailing_expr: then_parsed.trailing_expr, + location: then_parsed.location, + })); + + // Else branch: either if_expr or block + let else_item = self.expect_next(&mut inner, &loc, "else branch")?; + let location = Some(loc); + let else_expr = match else_item.as_rule() { + Rule::if_expr => self.parse_if_expr(else_item)?, + Rule::block => { + let else_parsed = self.parse_block(else_item)?; + Expr::Block(Box::new(BlockExpr { + label: else_parsed.label.clone().unwrap_or_default(), + attrs: else_parsed.attrs, + statements: else_parsed.statements, + trailing_expr: else_parsed.trailing_expr, + location: else_parsed.location, + })) + } + _ => { + return Err(ParseError { + message: format!( + "Expected block or if_expr in else branch, got {:?}", + else_item.as_rule() + ), + location: self.location(&else_item), + }); + } + }; + + Ok(Expr::If(Box::new(IfExpr { + condition, + then_expr, + else_expr, + location, + }))) + } + + /// Parse block expression. + fn parse_block_expr(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut statements = Vec::new(); + let mut trailing_expr = None; + let mut attrs = Vec::new(); + let mut label = String::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::attribute_list => { + attrs.extend(self.parse_attribute_list(item)?); + } + Rule::label => { + label = self.parse_label(item)?; + } + Rule::statement => { + statements.push(self.parse_statement(item)?); + } + Rule::trailing_expr => { + let expr_inner = self.expect_inner(item, "trailing_expr")?; + trailing_expr = Some(Box::new(self.parse_expr(expr_inner)?)); + } + _ => {} + } + } + + Ok(Expr::Block(Box::new(BlockExpr { + label, + attrs, + statements, + trailing_expr, + location, + }))) + } + + // ========================================================================= + // Types + // ========================================================================= + + /// Parse a type expression. + fn parse_type_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // type_expr = { type_prefix ~ type_suffix? } + // First get the type_prefix + let prefix_pair = self.expect_next(&mut inner, &location, "type_prefix")?; + let base_type = self.parse_type_prefix(prefix_pair)?; + + // Check for type_suffix (error union: E!T where E is error, T is payload) + if let Some(suffix_pair) = inner.next() + && suffix_pair.as_rule() == Rule::type_suffix + { + // type_suffix = { "!" ~ type_prefix } + // In E!T syntax: base_type is E (error), suffix is T (payload) + let payload_type_pair = self.expect_inner(suffix_pair, "type_suffix")?; + let payload_type = self.parse_type_prefix(payload_type_pair)?; + return Ok(TypeExpr::ErrorUnion(Box::new(ErrorUnionType { + error_type: base_type, + payload_type, + }))); + } + + Ok(base_type) + } + + /// Parse type_prefix. + fn parse_type_prefix(&self, pair: Pair<'a, Rule>) -> ParseResult { + // type_prefix contains one of the type alternatives + let inner = self.expect_inner(pair, "type_prefix")?; + + match inner.as_rule() { + Rule::optional_type => { + let inner_type = + self.parse_type_prefix(self.expect_inner(inner, "optional_type")?)?; + Ok(TypeExpr::Optional(Box::new(inner_type))) + } + Rule::pointer_type => self.parse_pointer_type(inner), + Rule::array_type => self.parse_array_type(inner), + Rule::tuple_type => self.parse_tuple_type(inner), + Rule::fn_type => self.parse_fn_type(inner), + Rule::set_type => self.parse_set_type(inner), + Rule::struct_literal => self.parse_struct_type(inner), + Rule::enum_literal => self.parse_enum_type(inner), + Rule::builtin_type => self.parse_builtin_type(inner), + Rule::type_identifier => self.parse_type_identifier(inner), + _ => Err(ParseError { + message: format!("unexpected type {:?}", inner.as_rule()), + location: self.location(&inner), + }), + } + } + + /// Parse inline struct type: struct { x: i32, y: i32 } + fn parse_struct_type(&self, pair: Pair) -> ParseResult { + let mut is_packed = false; + let mut fields = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::packed_keyword => { + is_packed = true; + } + Rule::struct_body => { + for body_item in item.into_inner() { + if body_item.as_rule() == Rule::struct_field { + fields.push(self.parse_struct_field(body_item)?); + } + } + } + _ => {} + } + } + + Ok(TypeExpr::Struct(Box::new(InlineStructType { + fields, + is_packed, + }))) + } + + /// Parse inline enum type: enum { a, b, c } or enum(u8) { a, b, c } + fn parse_enum_type(&self, pair: Pair) -> ParseResult { + let mut tag_type = None; + let mut variants = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::type_expr => { + tag_type = Some(self.parse_type_expr(item)?); + } + Rule::enum_body => { + for body_item in item.into_inner() { + if body_item.as_rule() == Rule::enum_variant { + variants.push(self.parse_enum_variant(body_item)?); + } + } + } + _ => {} + } + } + + Ok(TypeExpr::Enum(Box::new(InlineEnumType { + variants, + tag_type, + }))) + } + + /// Parse pointer type. + /// Grammar: pointer_type = { pointer_prefix ~ ws ~ ("const" ~ ws)? ~ type_prefix } + /// Grammar: pointer_prefix = { "[*:" ~ ws ~ expr ~ ws ~ "]" | "[*]" | "*" } + fn parse_pointer_type(&self, pair: Pair) -> ParseResult { + let inner = pair.into_inner(); + let mut is_many = false; + let mut is_const = false; + let mut sentinel = None; + let mut pointee = None; + + for item in inner { + match item.as_rule() { + Rule::pointer_prefix => { + // Parse the pointer prefix to determine is_many and sentinel + let prefix_str = item.as_str(); + if prefix_str == "*" { + // Single pointer: *T + is_many = false; + } else if prefix_str == "[*]" { + // Many pointer without sentinel: [*]T + is_many = true; + } else { + // Sentinel-terminated many pointer: [*:expr]T + is_many = true; + // The prefix contains an expr for the sentinel value + for prefix_item in item.into_inner() { + if prefix_item.as_rule() == Rule::expr { + sentinel = Some(self.parse_expr(prefix_item)?); + } + } + } + } + Rule::type_prefix => { + pointee = Some(self.parse_type_prefix(item)?); + } + _ => { + // Check for "const" keyword + if item.as_str() == "const" { + is_const = true; + } + } + } + } + + Ok(TypeExpr::Pointer(Box::new(PointerType { + pointee: pointee.expect("pointer needs pointee"), + is_const, + is_many, + sentinel, + }))) + } + + /// Parse array type. + fn parse_array_type(&self, pair: Pair) -> ParseResult { + let inner = pair.into_inner(); + let mut size = None; + let mut sentinel = None; + let mut element = None; + + for item in inner { + match item.as_rule() { + Rule::array_size => { + // array_size = { array_size_term ~ (ws ~ array_size_op ~ ws ~ array_size_term)* } + size = Some(self.parse_array_size_expr(item)?); + } + Rule::expr => { + // This is for sentinel: [N:sentinel]T + sentinel = Some(self.parse_expr(item)?); + } + Rule::type_prefix => { + // Grammar uses type_prefix for array element type + element = Some(self.parse_type_prefix(item)?); + } + _ => {} + } + } + + Ok(TypeExpr::Array(Box::new(ArrayType { + element: element.expect("array needs element type"), + size, + sentinel, + }))) + } + + /// Parse array size expression. + /// array_size = { array_size_term ~ (ws ~ array_size_op ~ ws ~ array_size_term)* } + fn parse_array_size_expr(&self, pair: Pair) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // Parse the first term + let first_term = inner.next().expect("array_size needs at least one term"); + let mut result = self.parse_array_size_term(first_term)?; + + // Parse remaining (op, term) pairs + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() == Rule::array_size_op { + let op = match op_pair.as_str() { + "+" => BinaryOp::Add, + "-" => BinaryOp::Sub, + "*" => BinaryOp::Mul, + "/" => BinaryOp::Div, + _ => unreachable!(), + }; + + let term_pair = inner.next().expect("operator needs right operand"); + let right = self.parse_array_size_term(term_pair)?; + + result = Expr::Binary(Box::new(BinaryExpr { + left: result, + op, + right, + location: Some(location.clone()), + })); + } + } + + Ok(result) + } + + /// Parse array size term. + /// array_size_term = { number_literal | identifier | "(" ~ ws ~ array_size ~ ws ~ ")" } + fn parse_array_size_term(&self, pair: Pair) -> ParseResult { + let inner = pair + .into_inner() + .next() + .expect("array_size_term needs content"); + match inner.as_rule() { + Rule::number_literal | Rule::identifier => self.parse_primary_expr(inner), + Rule::array_size => self.parse_array_size_expr(inner), + _ => unreachable!("unexpected rule in array_size_term: {:?}", inner.as_rule()), + } + } + + /// Parse function type. + fn parse_fn_type(&self, pair: Pair) -> ParseResult { + let inner = pair.into_inner(); + let mut params = Vec::new(); + let mut return_type = None; + + for item in inner { + match item.as_rule() { + Rule::type_list => { + // type_list = { type_prefix ~ (ws ~ "," ~ ws ~ type_prefix)* } + for ty in item.into_inner() { + if ty.as_rule() == Rule::type_prefix { + params.push(self.parse_type_prefix(ty)?); + } + } + } + Rule::type_prefix => { + // Return type is type_prefix + return_type = Some(self.parse_type_prefix(item)?); + } + _ => {} + } + } + + Ok(TypeExpr::Fn(Box::new(FnType { + params, + return_type, + }))) + } + + /// Parse tuple type: (T1, T2) or (T1, T2, T3, ...) + fn parse_tuple_type(&self, pair: Pair) -> ParseResult { + let mut elements = Vec::new(); + + for item in pair.into_inner() { + if item.as_rule() == Rule::type_prefix { + elements.push(self.parse_type_prefix(item)?); + } + } + + Ok(TypeExpr::Tuple(elements)) + } + + /// Parse set type: Set(T) + fn parse_set_type(&self, pair: Pair) -> ParseResult { + // set_type = { "Set" ~ ws ~ "(" ~ ws ~ type_expr ~ ws ~ ")" } + for item in pair.into_inner() { + if item.as_rule() == Rule::type_expr { + let element_type = self.parse_type_expr(item)?; + return Ok(TypeExpr::Set(Box::new(element_type))); + } + } + Err(ParseError { + message: "Set type requires element type".to_string(), + location: SourceLocation::default(), + }) + } + + /// Parse builtin type. + fn parse_builtin_type(&self, pair: Pair) -> ParseResult { + // Check for Self type first (has inner rule) + let inner = pair.clone().into_inner().next(); + if let Some(inner_pair) = inner + && inner_pair.as_rule() == Rule::self_type + { + return Ok(TypeExpr::Named(TypePath { + segments: vec!["Self".to_string()], + location: Some(self.location(&pair)), + })); + } + + let s = pair.as_str(); + + // Check for quantum types first + match s { + "qubit" => return Ok(TypeExpr::Qubit), + "bit" => return Ok(TypeExpr::Bit), + "Alloc" => return Ok(TypeExpr::QAlloc(None)), + "unit" => return Ok(TypeExpr::Unit), + "type" => return Ok(TypeExpr::Type), + "anytype" => return Ok(TypeExpr::AnyType), + "bool" => return Ok(TypeExpr::Primitive(PrimitiveType::Bool)), + _ => {} + } + + // Check for integer/float types + if let Some(prim) = self.parse_primitive_type(s) { + return Ok(TypeExpr::Primitive(prim)); + } + + Err(ParseError { + message: format!("unknown builtin type: {}", s), + location: self.location(&pair), + }) + } + + /// Parse primitive type from string. + /// Supports arbitrary bit-width integers like Zig: u1, u4, u7, u128, etc. + fn parse_primitive_type(&self, s: &str) -> Option { + // Special cases first + match s { + "usize" => return Some(PrimitiveType::Usize), + "isize" => return Some(PrimitiveType::Isize), + "f16" => return Some(PrimitiveType::F16), + "f32" => return Some(PrimitiveType::F32), + "f64" => return Some(PrimitiveType::F64), + "f128" => return Some(PrimitiveType::F128), + "a64" => return Some(PrimitiveType::A64), + "bool" => return Some(PrimitiveType::Bool), + _ => {} + } + + // Arbitrary-width integers: u or i + // Valid bit widths are 1-128 + if let Some(bits_str) = s.strip_prefix('u') { + if let Ok(bits) = bits_str.parse::() + && (1..=128).contains(&bits) + { + return Some(PrimitiveType::UInt { bits }); + } + } else if let Some(bits_str) = s.strip_prefix('i') + && let Ok(bits) = bits_str.parse::() + && (1..=128).contains(&bits) + { + return Some(PrimitiveType::IInt { bits }); + } + + None + } + + /// Parse type identifier (named type). + fn parse_type_identifier(&self, pair: Pair) -> ParseResult { + let mut segments = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::identifier { + segments.push(item.as_str().to_string()); + } + } + + Ok(TypeExpr::Named(TypePath { + segments, + location: None, + })) + } + + /// Parse a channel argument (positional or named). + fn parse_channel_arg(&self, pair: Pair) -> ParseResult { + // channel_arg = { (identifier ~ ws ~ ":" ~ ws ~ expr) | expr } + let mut name = None; + let mut value = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::identifier => { + name = Some(item.as_str().to_string()); + } + Rule::expr => { + value = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + let expr = value.expect("channel_arg requires expression"); + + if let Some(n) = name { + Ok(ChannelArg::Named { + name: n, + value: expr, + }) + } else { + Ok(ChannelArg::Positional(expr)) + } + } + + /// Parse a gate expression (either param_gate_expr or simple_gate_expr). + fn parse_gate_expr_inner( + &self, + pair: Pair, + location: Option, + ) -> ParseResult { + let mut gate_kind = None; + let mut params = Vec::new(); + let mut target = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::param_gate_keyword | Rule::simple_gate_keyword => { + gate_kind = Some(self.parse_gate_keyword(inner.as_str())?); + } + Rule::gate_params => { + // gate_params = { "(" ~ ws ~ arg_list ~ ws ~ ")" } + for param_inner in inner.into_inner() { + if param_inner.as_rule() == Rule::arg_list { + for arg in param_inner.into_inner() { + if arg.as_rule() == Rule::expr { + params.push(self.parse_expr(arg)?); + } + } + } + } + } + Rule::gate_target => { + // gate_target = { gate_set_target | tuple_expr | bracket_array | gate_qubit_target } + let target_inner = self.expect_inner(inner, "gate_target")?; + target = Some(match target_inner.as_rule() { + Rule::gate_set_target => { + // Parse as a set expression + let mut elements = Vec::new(); + for elem in target_inner.into_inner() { + if elem.as_rule() == Rule::batch_elements { + for e in elem.into_inner() { + if e.as_rule() == Rule::expr { + elements.push(self.parse_expr(e)?); + } + } + } + } + Expr::Set(Box::new(SetExpr { + elements, + element_type: None, + location: location.clone(), + })) + } + Rule::paren_or_tuple => self.parse_primary_expr(target_inner)?, + Rule::bracket_array => self.parse_primary_expr(target_inner)?, + Rule::postfix_expr => self.parse_postfix_expr(target_inner)?, + Rule::gate_qubit_target => { + // gate_qubit_target = { !operator_keyword ~ postfix_expr } + // Just parse the inner postfix_expr + let inner_expr = + self.expect_inner(target_inner, "gate_qubit_target")?; + self.parse_postfix_expr(inner_expr)? + } + other => { + return Err(ParseError { + message: format!( + "unexpected rule {:?}, expected gate_target", + other + ), + location: location.clone().unwrap_or_default(), + }); + } + }); + } + _ => {} + } + } + + Ok(Expr::Gate(Box::new(GateExpr { + kind: gate_kind.expect("gate requires keyword"), + params, + target: target.expect("gate requires target"), + location, + }))) + } + + /// Parse gate keyword to GateKind. + fn parse_gate_keyword(&self, s: &str) -> ParseResult { + use GateKind::*; + match s { + // Single-qubit Pauli gates + "x" => Ok(X), + "y" => Ok(Y), + "z" => Ok(Z), + // Hadamard + "h" => Ok(H), + // T gates (fourth root of Z) + "t" => Ok(T), + "tdg" => Ok(Tdg), + // Square root gates + "sx" => Ok(SX), + "sy" => Ok(SY), + "sz" => Ok(SZ), + "sxdg" => Ok(SXdg), + "sydg" => Ok(SYdg), + "szdg" => Ok(SZdg), + // Rotation gates + "rx" => Ok(RX), + "ry" => Ok(RY), + "rz" => Ok(RZ), + // Two-qubit gates + "cx" => Ok(CX), + "cy" => Ok(CY), + "cz" => Ok(CZ), + "ch" => Ok(CH), + // Two-qubit rotation gates + "sxx" => Ok(SXX), + "syy" => Ok(SYY), + "szz" => Ok(SZZ), + "sxxdg" => Ok(SXXdg), + "syydg" => Ok(SYYdg), + "szzdg" => Ok(SZZdg), + "rzz" => Ok(RZZ), + "crz" => Ok(RZZ), // CRZ is effectively RZZ + // Swap gates + "swap" => Ok(SWAP), + "iswap" => Ok(ISWAP), + // Three-qubit gates + "ccx" => Ok(CCX), // Toffoli gate + // Face rotations + "f" => Ok(F), + "fdg" => Ok(Fdg), + "f4" => Ok(F4), + "f4dg" => Ok(F4dg), + // Prepare operation + "pz" => Ok(PZ), + other => { + let suggestion = suggest_gate_name(other); + let message = match suggestion { + Some(name) => format!("unknown gate '{}', did you mean '{}'?", other, name), + None => format!("unknown gate '{}'", other), + }; + Err(ParseError { + message, + location: SourceLocation::default(), + }) + } + } + } +} + +/// Known gate names for suggestions. +const KNOWN_GATE_NAMES: &[&str] = &[ + "x", "y", "z", "h", "t", "tdg", "sx", "sy", "sz", "sxdg", "sydg", "szdg", "rx", "ry", "rz", + "cx", "cy", "cz", "ch", "sxx", "syy", "szz", "sxxdg", "syydg", "szzdg", "rzz", "crz", "swap", + "iswap", "ccx", "f", "fdg", "f4", "f4dg", "pz", +]; + +/// Deprecated gate name mappings. +const DEPRECATED_GATES: &[(&str, &str)] = &[("s", "sz"), ("sdg", "szdg")]; + +/// Suggest a gate name for a misspelled or deprecated gate keyword. +pub fn suggest_gate_name(unknown: &str) -> Option<&'static str> { + // Check deprecated names first + for &(old, new) in DEPRECATED_GATES { + if unknown == old { + return Some(new); + } + } + + // Find closest match by edit distance + let mut best: Option<(&str, usize)> = None; + for &name in KNOWN_GATE_NAMES { + let dist = edit_distance(unknown, name); + if dist <= 2 { + match best { + Some((_, best_dist)) if dist < best_dist => best = Some((name, dist)), + None => best = Some((name, dist)), + _ => {} + } + } + } + best.map(|(name, _)| name) +} + +/// Compute the Levenshtein edit distance between two strings. +pub fn edit_distance(a: &str, b: &str) -> usize { + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + let m = a_bytes.len(); + let n = b_bytes.len(); + + // Use single-row optimization + let mut prev = vec![0usize; n + 1]; + for (j, slot) in prev.iter_mut().enumerate() { + *slot = j; + } + + for i in 1..=m { + let mut curr = vec![0usize; n + 1]; + curr[0] = i; + for j in 1..=n { + let cost = if a_bytes[i - 1] == b_bytes[j - 1] { + 0 + } else { + 1 + }; + curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost); + } + prev = curr; + } + + prev[n] +} + +/// Parse a Zluppy source string. +pub fn parse(source: &str) -> ParseResult { + log::debug!("Parsing {} bytes of source", source.len()); + let result = ParserState::new(source).parse(); + match &result { + Ok(program) => { + log::debug!( + "Parsed {} top-level declarations", + program.declarations.len() + ); + log::trace!("AST: {:?}", program); + } + Err(e) => { + log::debug!("Parse error: {}", e); + } + } + result +} + +/// Parse a Zluppy source file. +pub fn parse_file(source: &str, filename: impl Into) -> ParseResult { + let filename = filename.into(); + log::debug!("Parsing file '{}' ({} bytes)", filename, source.len()); + let result = ParserState::new(source).with_file(&filename).parse(); + match &result { + Ok(program) => { + log::debug!( + "Parsed {} top-level declarations from '{}'", + program.declarations.len(), + filename + ); + } + Err(e) => { + log::debug!("Parse error in '{}': {}", filename, e); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty() { + let result = parse(""); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_const() { + // Immutable binding with explicit type + let result = parse("x: u32 = 42;"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_function() { + let result = parse("fn main() -> unit { return unit; }"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_binding_function_call() { + // Mutable binding with inferred type + let source = "mut q := qalloc(2);"; + let result = parse(source); + assert!(result.is_ok(), "Parse failed: {:?}", result); + let program = result.unwrap(); + + // Check that there's exactly one declaration + assert_eq!(program.declarations.len(), 1, "Expected 1 declaration"); + + // Check that it's a binding declaration + if let TopLevelDecl::Binding(binding) = &program.declarations[0] { + assert_eq!(binding.name, "q"); + assert!(binding.is_mutable); + // Check that the value is a Call expression + if let Some(Expr::Call(call)) = &binding.value { + if let Expr::Ident(ident) = &call.callee { + assert_eq!(ident.name, "qalloc"); + } else { + panic!("Callee should be Ident, got: {:?}", call.callee); + } + assert_eq!(call.args.len(), 1, "Expected 1 argument"); + } else { + panic!("Value should be Call, got: {:?}", binding.value); + } + } else { + panic!( + "Should be a Binding declaration, got: {:?}", + program.declarations[0] + ); + } + } + + #[test] + fn test_parse_true_and_expr() { + // Test that `true and y` parses as an expression + let source = "x := true and y;\n"; + let result = parse(source); + assert!(result.is_ok(), "Parse failed: {:?}", result); + } + + #[test] + fn test_parse_just_true() { + // Test just `true` as expression - this works + let source = "x := true;\n"; + let result = parse(source); + assert!(result.is_ok(), "Parse failed: {:?}", result); + } + + #[test] + fn test_parse_ident_and_true() { + // Test `y and true` - boolean on right works + let source = "x := y and true;\n"; + let result = parse(source); + assert!(result.is_ok(), "Parse failed: {:?}", result); + } + + // ========================================================================= + // Input Size Limit Tests + // ========================================================================= + + #[test] + fn test_max_source_size_constant() { + // Verify the constant is reasonable (10MB) + assert_eq!(MAX_SOURCE_SIZE, 10 * 1024 * 1024); + } + + #[test] + fn test_source_size_limit_enforced() { + // Create a source that exceeds the limit + let large_source = "x".repeat(MAX_SOURCE_SIZE + 1); + let result = parse(&large_source); + assert!(result.is_err(), "Expected error for oversized source"); + let err = result.unwrap_err(); + assert!( + err.message.contains("too large"), + "Expected 'too large' in error message: {}", + err.message + ); + } + + #[test] + fn test_normal_source_within_limit() { + // Normal source should parse fine + let source = "fn main() -> unit { return unit; }"; + assert!(source.len() < MAX_SOURCE_SIZE); + let result = parse(source); + assert!( + result.is_ok(), + "Expected normal source to parse: {:?}", + result + ); + } +} diff --git a/exp/zlup/src/pretty.rs b/exp/zlup/src/pretty.rs new file mode 100644 index 000000000..f44e2c917 --- /dev/null +++ b/exp/zlup/src/pretty.rs @@ -0,0 +1,1910 @@ +//! AST-based pretty printer for Zlup. +//! +//! Provides canonical formatting by parsing source to AST and +//! pretty-printing with consistent style rules. + +use crate::ast::*; + +/// Pretty printing options. +#[derive(Debug, Clone)] +pub struct PrettyOptions { + /// Use spaces instead of tabs. + pub use_spaces: bool, + /// Number of spaces per indent level (if using spaces). + pub indent_size: usize, + /// Maximum line length before wrapping. + pub max_line_length: usize, +} + +impl Default for PrettyOptions { + fn default() -> Self { + Self { + use_spaces: true, + indent_size: 4, + max_line_length: 100, + } + } +} + +/// AST-based pretty printer. +pub struct PrettyPrinter { + options: PrettyOptions, + output: String, + indent_level: usize, + at_line_start: bool, +} + +impl PrettyPrinter { + pub fn new(options: PrettyOptions) -> Self { + Self { + options, + output: String::new(), + indent_level: 0, + at_line_start: true, + } + } + + /// Pretty print a program. + pub fn print_program(&mut self, program: &Program) -> String { + for (i, decl) in program.declarations.iter().enumerate() { + if i > 0 { + self.newline(); + } + self.print_top_level_decl(decl); + } + self.ensure_trailing_newline(); + std::mem::take(&mut self.output) + } + + fn indent_str(&self) -> String { + if self.options.use_spaces { + " ".repeat(self.options.indent_size) + } else { + "\t".to_string() + } + } + + fn write(&mut self, s: &str) { + if self.at_line_start && !s.is_empty() { + for _ in 0..self.indent_level { + self.output.push_str(&self.indent_str()); + } + self.at_line_start = false; + } + self.output.push_str(s); + } + + fn newline(&mut self) { + self.output.push('\n'); + self.at_line_start = true; + } + + fn ensure_trailing_newline(&mut self) { + if !self.output.ends_with('\n') { + self.output.push('\n'); + } + } + + fn indent(&mut self) { + self.indent_level += 1; + } + + fn dedent(&mut self) { + if self.indent_level > 0 { + self.indent_level -= 1; + } + } + + // ========================================================================= + // Top-level declarations + // ========================================================================= + + fn print_top_level_decl(&mut self, decl: &TopLevelDecl) { + match decl { + TopLevelDecl::Binding(b) => self.print_binding(b), + TopLevelDecl::Fn(f) => self.print_fn_decl(f), + TopLevelDecl::ExternFn(f) => self.print_extern_fn_decl(f), + TopLevelDecl::Struct(s) => self.print_struct_decl(s), + TopLevelDecl::Enum(e) => self.print_enum_decl(e), + TopLevelDecl::Union(u) => self.print_union_decl(u), + TopLevelDecl::ErrorSet(e) => self.print_error_set_decl(e), + TopLevelDecl::FaultSet(f) => self.print_fault_set_decl(f), + TopLevelDecl::Test(t) => self.print_test_decl(t), + TopLevelDecl::DeclareGate(g) => { + self.write(&format!("declare gate {}(", g.name)); + for (i, p) in g.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&p.name); + } + self.write(")("); + for (i, q) in g.qubits.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&q.name); + } + self.write(");"); + self.newline(); + } + TopLevelDecl::Gate(g) => { + self.write(&format!("gate {}(", g.name)); + for (i, p) in g.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&p.name); + } + self.write(")("); + for (i, q) in g.qubits.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&q.name); + } + self.write(") "); + self.print_block(&g.body); + self.newline(); + } + } + } + + fn print_doc_comment(&mut self, doc: &Option) { + if let Some(doc) = doc { + for line in doc.lines() { + self.write("/// "); + self.write(line); + self.newline(); + } + } + } + + fn print_binding(&mut self, binding: &Binding) { + self.print_doc_comment(&binding.doc_comment); + + if binding.is_pub { + self.write("pub "); + } + if binding.is_mutable { + self.write("mut "); + } + self.write(&binding.name); + + if let Some(ty) = &binding.ty { + self.write(": "); + self.print_type_expr(ty); + } + + if let Some(value) = &binding.value { + if binding.ty.is_some() { + self.write(" = "); + } else { + self.write(" := "); + } + self.print_expr(value); + } + + self.write(";"); + self.newline(); + } + + fn print_fn_decl(&mut self, func: &FnDecl) { + self.print_doc_comment(&func.doc_comment); + + if func.is_pub { + self.write("pub "); + } + if func.is_inline { + self.write("inline "); + } + + self.write("fn "); + self.write(&func.name); + self.write("("); + + for (i, param) in func.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_param(param); + } + + self.write(")"); + + if let Some(ret) = &func.return_type { + self.write(" -> "); + self.print_type_expr(ret); + } + + self.write(" "); + self.print_block(&func.body); + self.newline(); + } + + fn print_param(&mut self, param: &Param) { + // Check for Rust-style self parameter + if param.name == "self" + && let TypeExpr::Pointer(ptr) = ¶m.ty + && let TypeExpr::Named(path) = &ptr.pointee + && path.segments == vec!["Self".to_string()] + { + // This is a self parameter - print as &self or &mut self + if ptr.is_const { + self.write("&self"); + } else { + self.write("&mut self"); + } + return; + } + + // Regular parameter + if param.is_comptime { + self.write("comptime "); + } + self.write(¶m.name); + self.write(": "); + self.print_type_expr(¶m.ty); + } + + fn print_extern_fn_decl(&mut self, func: &ExternFnDecl) { + self.print_doc_comment(&func.doc_comment); + + if let Some(lib) = &func.library { + self.write("@link(\""); + self.write(lib); + self.write("\") "); + } + + if func.is_pub { + self.write("pub "); + } + + self.write("extern \""); + self.write(&func.calling_convention); + self.write("\" fn "); + self.write(&func.name); + self.write("("); + + for (i, param) in func.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_param(param); + } + + self.write(")"); + + if let Some(ret) = &func.return_type { + self.write(" -> "); + self.print_type_expr(ret); + } + + self.write(";"); + self.newline(); + } + + fn print_struct_decl(&mut self, s: &StructDecl) { + self.print_doc_comment(&s.doc_comment); + + if s.is_pub { + self.write("pub "); + } + + self.write("const "); + self.write(&s.name); + self.write(" = "); + + if s.is_packed { + self.write("packed "); + } + self.write("struct {"); + + if s.fields.is_empty() && s.methods.is_empty() && s.associated_consts.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for field in &s.fields { + self.print_struct_field(field); + } + + for method in &s.methods { + self.newline(); + self.print_fn_decl(method); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_struct_field(&mut self, field: &StructField) { + self.print_doc_comment(&field.doc_comment); + self.write(&field.name); + self.write(": "); + self.print_type_expr(&field.ty); + + if let Some(default) = &field.default { + self.write(" = "); + self.print_expr(default); + } + + self.write(","); + self.newline(); + } + + fn print_enum_decl(&mut self, e: &EnumDecl) { + self.print_doc_comment(&e.doc_comment); + + if e.is_pub { + self.write("pub "); + } + + self.write("const "); + self.write(&e.name); + self.write(" = enum"); + + if let Some(tag) = &e.tag_type { + self.write("("); + self.print_type_expr(tag); + self.write(")"); + } + + self.write(" {"); + + if e.variants.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for variant in &e.variants { + self.write(&variant.name); + if let Some(val) = &variant.value { + self.write(" = "); + self.print_expr(val); + } + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_union_decl(&mut self, u: &UnionDecl) { + self.print_doc_comment(&u.doc_comment); + + if u.is_pub { + self.write("pub "); + } + + self.write("const "); + self.write(&u.name); + self.write(" = union"); + + match &u.tag { + Some(Some(ty)) => { + self.write("("); + self.print_type_expr(ty); + self.write(")"); + } + Some(None) => { + self.write("(enum)"); + } + None => {} + } + + self.write(" {"); + + if u.fields.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for field in &u.fields { + self.write(&field.name); + if let Some(ty) = &field.ty { + self.write(": "); + self.print_type_expr(ty); + } + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_error_set_decl(&mut self, e: &ErrorSetDecl) { + self.print_doc_comment(&e.doc_comment); + + if e.is_pub { + self.write("pub "); + } + + self.write(&e.name); + self.write(" := error {"); + + if e.variants.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for variant in &e.variants { + self.write(&variant.name); + if let Some(ty) = &variant.data_type { + self.write(": "); + self.print_type_expr(ty); + } + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_fault_set_decl(&mut self, f: &FaultSetDecl) { + self.print_doc_comment(&f.doc_comment); + + if f.is_pub { + self.write("pub "); + } + + self.write(&f.name); + self.write(" := fault {"); + + if f.variants.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for variant in &f.variants { + self.write(&variant.name); + if let Some(ty) = &variant.data_type { + self.write(": "); + self.print_type_expr(ty); + } + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_test_decl(&mut self, t: &TestDecl) { + self.write("test \""); + self.write(&t.name); + self.write("\" "); + self.print_block(&t.body); + self.newline(); + } + + // ========================================================================= + // Statements + // ========================================================================= + + fn print_stmt(&mut self, stmt: &Stmt) { + match stmt { + Stmt::Binding(b) => self.print_binding(b), + Stmt::Alias(a) => self.print_alias_binding(a), + Stmt::Assign(a) => self.print_assign_stmt(a), + Stmt::If(i) => self.print_if_stmt(i), + Stmt::For(f) => self.print_for_stmt(f), + Stmt::Switch(s) => self.print_switch_stmt(s), + Stmt::Tick(t) => self.print_tick_stmt(t), + Stmt::TryBlock(t) => self.print_try_block_stmt(t), + Stmt::Return(r) => self.print_return_stmt(r), + Stmt::Break(b) => self.print_break_stmt(b), + Stmt::Continue(c) => self.print_continue_stmt(c), + Stmt::Defer(d) => self.print_defer_stmt(d), + Stmt::Errdefer(e) => self.print_errdefer_stmt(e), + Stmt::Block(b) => { + self.print_block(b); + self.newline(); + } + Stmt::Expr(e) => self.print_expr_stmt(e), + Stmt::Gate(g) => self.print_gate_op(g), + Stmt::Prepare(p) => self.print_prepare_op(p), + Stmt::Measure(m) => self.print_measure_op(m), + Stmt::Barrier(b) => self.print_barrier_op(b), + } + } + + fn print_alias_binding(&mut self, a: &AliasBinding) { + self.write("alias "); + self.write(&a.name); + self.write(" := "); + self.print_expr(&a.source); + self.write(";"); + self.newline(); + } + + fn print_assign_stmt(&mut self, a: &AssignStmt) { + self.print_expr(&a.target); + self.write(" "); + self.write(match a.op { + AssignOp::Assign => "=", + AssignOp::AddAssign => "+=", + AssignOp::SubAssign => "-=", + AssignOp::MulAssign => "*=", + AssignOp::DivAssign => "/=", + AssignOp::AndAssign => "&=", + AssignOp::OrAssign => "|=", + AssignOp::XorAssign => "^=", + }); + self.write(" "); + self.print_expr(&a.value); + self.write(";"); + self.newline(); + } + + fn print_if_stmt(&mut self, i: &IfStmt) { + self.write("if ("); + self.print_expr(&i.condition); + self.write(")"); + + if let Some(cap) = &i.capture { + self.write(" |"); + self.write(cap); + self.write("|"); + } + + self.write(" "); + self.print_block(&i.then_body); + + if let Some(else_branch) = &i.else_body { + self.write(" else "); + match else_branch { + ElseBranch::ElseIf(elif) => self.print_if_stmt(elif), + ElseBranch::Else(block) => { + self.print_block(block); + self.newline(); + } + } + } else { + self.newline(); + } + } + + fn print_for_stmt(&mut self, f: &ForStmt) { + if let Some(label) = &f.label { + self.write(label); + self.write(": "); + } + + if f.is_inline { + self.write("inline "); + } + + self.write("for "); + + // Print captures (loop variables) first: for i, j in ... + if f.captures.is_empty() { + self.write("_ "); + } else { + for (i, cap) in f.captures.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(cap); + } + self.write(" "); + } + + self.write("in "); + + match &f.range { + ForRange::Range { start, end } => { + self.print_expr(start); + self.write(".."); + self.print_expr(end); + } + ForRange::Collection(coll) => { + self.print_expr(coll); + } + } + + self.write(" "); + self.print_block(&f.body); + self.newline(); + } + + fn print_switch_stmt(&mut self, s: &SwitchStmt) { + self.write("switch ("); + self.print_expr(&s.value); + self.write(") {"); + self.newline(); + self.indent(); + + for prong in &s.prongs { + if prong.is_else { + self.write("else"); + } else { + for (i, case) in prong.cases.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(&case.value); + if let Some(end) = &case.end { + self.write(".."); + self.print_expr(end); + } + } + } + self.write(" => "); + self.print_expr(&prong.body); + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("}"); + self.newline(); + } + + fn print_tick_stmt(&mut self, t: &TickStmt) { + // Print attributes + for attr in &t.attrs { + self.print_attribute(attr); + self.newline(); + } + + self.write("tick"); + if let Some(label) = &t.label { + self.write("(\""); + self.write(label); + self.write("\")"); + } + self.write(" {"); + + if t.body.is_empty() { + self.write("}"); + } else { + self.newline(); + self.indent(); + + for stmt in &t.body { + self.print_stmt(stmt); + } + + self.dedent(); + self.write("}"); + } + self.newline(); + } + + fn print_try_block_stmt(&mut self, t: &TryBlockStmt) { + match t.mode { + TryMode::Collect => self.write("try "), + TryMode::Propagate => self.write("try! "), + } + self.print_block(&t.body); + + if let Some(catch) = &t.catch_clause { + self.write(" catch |"); + self.write(&catch.capture); + self.write("| "); + self.print_expr(&catch.body); + } + self.newline(); + } + + fn print_return_stmt(&mut self, r: &ReturnStmt) { + self.write("return"); + // Simplify `return unit;` to `return;` for cleaner output + if let Some(val) = &r.value + && !matches!(val, Expr::Unit(_)) + { + self.write(" "); + self.print_expr(val); + } + self.write(";"); + self.newline(); + } + + fn print_break_stmt(&mut self, b: &BreakStmt) { + self.write("break"); + if let Some(label) = &b.label { + self.write(" :"); + self.write(label); + } + if let Some(val) = &b.value { + self.write(" "); + self.print_expr(val); + } + self.write(";"); + self.newline(); + } + + fn print_continue_stmt(&mut self, c: &ContinueStmt) { + self.write("continue"); + if let Some(label) = &c.label { + self.write(" :"); + self.write(label); + } + self.write(";"); + self.newline(); + } + + fn print_defer_stmt(&mut self, d: &DeferStmt) { + self.write("defer "); + // Defer body is printed inline, not as a full statement + self.print_stmt_inline(&d.body); + self.newline(); + } + + fn print_errdefer_stmt(&mut self, e: &ErrDeferStmt) { + self.write("errdefer"); + if let Some(cap) = &e.capture { + self.write(" |"); + self.write(cap); + self.write("|"); + } + self.write(" "); + self.print_stmt_inline(&e.body); + self.newline(); + } + + fn print_stmt_inline(&mut self, stmt: &Stmt) { + // Print statement without trailing newline + match stmt { + Stmt::Expr(e) => { + self.print_expr(&e.expr); + self.write(";"); + } + Stmt::Block(b) => self.print_block(b), + _ => self.print_stmt(stmt), + } + } + + fn print_expr_stmt(&mut self, e: &ExprStmt) { + // Print attributes + for attr in &e.attrs { + self.print_attribute(attr); + self.newline(); + } + + self.print_expr(&e.expr); + self.write(";"); + self.newline(); + } + + fn print_attribute(&mut self, attr: &Attribute) { + self.write("@"); + self.write(&attr.name); + if let Some(val) = &attr.value { + self.write("("); + match val { + AttributeValue::Bool(b) => self.write(if *b { "true" } else { "false" }), + AttributeValue::Int(i) => self.write(&i.to_string()), + AttributeValue::Float(f) => self.write(&f.to_string()), + AttributeValue::String(s) => { + self.write("\""); + self.write(s); + self.write("\""); + } + AttributeValue::Ident(i) => self.write(i), + } + self.write(")"); + } + } + + // ========================================================================= + // Quantum operations + // ========================================================================= + + fn print_gate_op(&mut self, g: &GateOp) { + self.write(&format!("{:?}", g.kind).to_lowercase()); + + if !g.params.is_empty() { + self.write("("); + for (i, param) in g.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(param); + } + self.write(")"); + } + + self.write(" "); + + if g.targets.len() == 1 { + self.print_slot_ref(&g.targets[0]); + } else { + self.write("("); + for (i, target) in g.targets.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_slot_ref(target); + } + self.write(")"); + } + + self.write(";"); + self.newline(); + } + + fn print_slot_ref(&mut self, slot: &SlotRef) { + self.write(&slot.allocator); + self.write("["); + self.print_expr(&slot.index); + self.write("]"); + } + + fn print_prepare_op(&mut self, p: &PrepareOp) { + self.write("pz "); + if let Some(slots) = &p.slots { + // pz {q[0], q[1], ...}; + self.write("{"); + for (i, slot) in slots.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&format!("{}[{}]", p.allocator, slot)); + } + self.write("}"); + } else { + // pz q; + self.write(&p.allocator); + } + self.write(";"); + self.newline(); + } + + fn print_measure_op(&mut self, m: &MeasureOp) { + self.write("measure("); + for (i, target) in m.targets.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_slot_ref(target); + } + self.write(")"); + + if !m.results.is_empty() { + self.write(" -> "); + for (i, result) in m.results.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&result.register); + self.write("["); + self.print_expr(&result.index); + self.write("]"); + } + } + + self.write(";"); + self.newline(); + } + + fn print_barrier_op(&mut self, b: &BarrierOp) { + self.write("barrier("); + for (i, alloc) in b.allocators.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(alloc); + } + self.write(");"); + self.newline(); + } + + // ========================================================================= + // Blocks + // ========================================================================= + + fn print_block(&mut self, block: &Block) { + // Print block attributes + for attr in &block.attrs { + self.print_attribute(attr); + self.newline(); + } + + if let Some(label) = &block.label { + self.write(label); + self.write(": "); + } + + self.write("{"); + + if block.statements.is_empty() && block.trailing_expr.is_none() { + self.write("}"); + } else { + self.newline(); + self.indent(); + + for stmt in &block.statements { + self.print_stmt(stmt); + } + + if let Some(expr) = &block.trailing_expr { + self.print_expr(expr); + self.newline(); + } + + self.dedent(); + self.write("}"); + } + } + + // ========================================================================= + // Expressions + // ========================================================================= + + fn print_expr(&mut self, expr: &Expr) { + match expr { + Expr::IntLit(lit) => { + self.write(&lit.value.to_string()); + if let Some(suffix) = &lit.suffix { + self.write("_"); + self.write(suffix); + } + } + Expr::FloatLit(lit) => { + self.write(&lit.value.to_string()); + if let Some(suffix) = &lit.suffix { + self.write("_"); + self.write(suffix); + } + } + Expr::AngleLit(angle) => { + self.print_expr(&angle.value); + self.write(" "); + self.write(match angle.unit { + AngleUnit::Turns => "turns", + AngleUnit::Rad => "rad", + }); + } + Expr::TypeAscription(asc) => { + self.print_expr(&asc.value); + self.write(" "); + self.write(&asc.type_name); + } + Expr::BoolLit(lit) => { + self.write(if lit.value { "true" } else { "false" }); + } + Expr::StringLit(lit) => { + self.write("\""); + self.write(&escape_string(&lit.value)); + self.write("\""); + } + Expr::FString(fstr) => { + self.write("f\""); + for part in &fstr.parts { + match part { + FStringPart::Text(text) => { + self.write(&escape_fstring_text(text)); + } + FStringPart::Expr { expr, format } => { + self.write("{"); + self.print_expr(expr); + if let Some(fmt) = format { + self.write(":"); + self.write(fmt); + } + self.write("}"); + } + } + } + self.write("\""); + } + Expr::CharLit(lit) => { + self.write("'"); + self.write(&escape_char(lit.value)); + self.write("'"); + } + Expr::Null(_) => self.write("none"), + Expr::Undefined(_) => self.write("undefined"), + Expr::Unit(_) => self.write("unit"), + Expr::Ident(ident) => self.write(&ident.name), + Expr::SlotRef(slot) => self.print_slot_ref(slot), + Expr::BitRef(bit) => { + self.write(&bit.register); + self.write("["); + self.print_expr(&bit.index); + self.write("]"); + } + Expr::Binary(bin) => self.print_binary_expr(bin), + Expr::Unary(un) => self.print_unary_expr(un), + Expr::Field(field) => { + self.print_expr(&field.object); + self.write("."); + self.write(&field.field); + } + Expr::Index(idx) => { + self.print_expr(&idx.object); + self.write("["); + self.print_expr(&idx.index); + self.write("]"); + } + Expr::Call(call) => { + self.print_expr(&call.callee); + self.write("("); + for (i, arg) in call.args.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(arg); + } + self.write(")"); + } + Expr::BatchApply(batch) => { + self.print_expr(&batch.operation); + self.write(" {"); + for (i, target) in batch.targets.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(target); + } + self.write("}"); + } + Expr::If(if_expr) => { + self.write("if ("); + self.print_expr(&if_expr.condition); + self.write(") "); + self.print_expr(&if_expr.then_expr); + self.write(" else "); + self.print_expr(&if_expr.else_expr); + } + Expr::Block(block) => { + for attr in &block.attrs { + self.print_attribute(attr); + self.write(" "); + } + self.write(&block.label); + self.write(": {"); + if block.statements.is_empty() && block.trailing_expr.is_none() { + self.write("}"); + } else { + self.newline(); + self.indent(); + for stmt in &block.statements { + self.print_stmt(stmt); + } + if let Some(expr) = &block.trailing_expr { + self.print_expr(expr); + self.newline(); + } + self.dedent(); + self.write("}"); + } + } + Expr::Comptime(ct) => { + self.write("comptime "); + self.print_expr(&ct.inner); + } + Expr::Builtin(bi) => { + self.write("@"); + self.write(&bi.name); + self.write("("); + for (i, arg) in bi.args.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(arg); + } + self.write(")"); + } + Expr::AnonStruct(anon) => { + if anon.is_packed { + self.write("packed "); + } + self.write("struct {"); + if anon.fields.is_empty() { + self.write("}"); + } else { + self.newline(); + self.indent(); + for field in &anon.fields { + self.print_struct_field(field); + } + self.dedent(); + self.write("}"); + } + } + Expr::StructInit(init) => { + if let Some(ty) = &init.ty { + self.print_type_expr(ty); + self.write(" "); + } else { + // Anonymous struct uses .{ } syntax + self.write("."); + } + self.write("{"); + for (i, field) in init.fields.iter().enumerate() { + if i > 0 { + self.write(", "); + } + // Check for shorthand: field name matches identifier value + let is_shorthand = + matches!(&field.value, Expr::Ident(ident) if ident.name == field.name); + if is_shorthand { + // Shorthand: just `name` instead of `name: name` + self.write(&field.name); + } else { + // Rust-style: `name: value` + self.write(&field.name); + self.write(": "); + self.print_expr(&field.value); + } + } + self.write("}"); + } + Expr::ArrayInit(init) => { + if let Some(ty) = &init.ty { + self.print_type_expr(ty); + } + self.write("{"); + for (i, elem) in init.elements.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(elem); + } + self.write("}"); + } + Expr::BracketArray(arr) => { + self.write("["); + for (i, elem) in arr.elements.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(elem); + } + self.write("]"); + } + Expr::Tuple(tuple) => { + self.write("("); + for (i, elem) in tuple.elements.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(elem); + } + self.write(")"); + } + Expr::Set(set) => { + if let Some(ty) = &set.element_type { + self.write("Set("); + self.print_type_expr(ty); + self.write(")"); + } + self.write("{"); + for (i, elem) in set.elements.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(elem); + } + self.write("}"); + } + Expr::Range(range) => { + if let Some(start) = &range.start { + self.print_expr(start); + } + self.write(".."); + if let Some(end) = &range.end { + self.print_expr(end); + } + } + Expr::Measure(m) => { + self.write("mz("); + if m.pack { + self.write("pack "); + } + self.print_type_expr(&m.result_type); + self.write(") "); + self.print_expr(&m.targets); + } + Expr::Gate(g) => { + self.write(&format!("{:?}", g.kind).to_lowercase()); + if !g.params.is_empty() { + self.write("("); + for (i, param) in g.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(param); + } + self.write(")"); + } + self.write(" "); + self.print_expr(&g.target); + } + Expr::ErrorValue(err) => { + self.write("error."); + self.write(&err.name); + } + Expr::FaultValue(fault) => { + self.write("fault."); + self.write(&fault.name); + } + Expr::Catch(c) => { + self.print_expr(&c.operand); + self.write(" catch"); + if let Some(cap) = &c.capture { + self.write(" |"); + self.write(cap); + self.write("|"); + } + self.write(" "); + self.print_expr(&c.handler); + } + Expr::TryBlock(t) => { + match t.mode { + TryMode::Collect => self.write("try "), + TryMode::Propagate => self.write("try! "), + } + self.print_block(&t.body); + if let Some(catch) = &t.catch_clause { + self.write(" catch |"); + self.write(&catch.capture); + self.write("| "); + self.print_expr(&catch.body); + } + } + Expr::FnLit(f) => { + self.write("fn("); + for (i, param) in f.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_param(param); + } + self.write(")"); + if let Some(ret) = &f.return_type { + self.write(" -> "); + self.print_type_expr(ret); + } + self.write(" "); + self.print_block(&f.body); + } + Expr::Channel(channel) => { + self.write("@emit."); + self.write(&channel.channel); + self.write("."); + self.write(&channel.command); + self.write("("); + for (i, arg) in channel.args.iter().enumerate() { + if i > 0 { + self.write(", "); + } + match arg { + ChannelArg::Positional(expr) => { + self.print_expr(expr); + } + ChannelArg::Named { name, value } => { + self.write(name); + self.write(": "); + self.print_expr(value); + } + } + } + self.write(")"); + } + Expr::Result(result) => { + self.write("result(\""); + self.write(&escape_string(&result.tag)); + self.write("\", "); + self.print_expr(&result.value); + self.write(")"); + } + } + } + + fn print_binary_expr(&mut self, bin: &BinaryExpr) { + let needs_parens = matches!( + bin.op, + BinaryOp::And | BinaryOp::Or | BinaryOp::Orelse | BinaryOp::Catch + ); + + if needs_parens { + self.write("("); + } + + self.print_expr(&bin.left); + + self.write(" "); + self.write(match bin.op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Mul => "*", + BinaryOp::Div => "/", + BinaryOp::Mod => "%", + BinaryOp::Eq => "==", + BinaryOp::Ne => "!=", + BinaryOp::Lt => "<", + BinaryOp::Le => "<=", + BinaryOp::Gt => ">", + BinaryOp::Ge => ">=", + BinaryOp::In => "in", + BinaryOp::NotIn => "not in", + BinaryOp::And => "and", + BinaryOp::Or => "or", + BinaryOp::Orelse => "orelse", + BinaryOp::Catch => "catch", + BinaryOp::BitAnd => "&", + BinaryOp::BitOr => "|", + BinaryOp::BitXor => "^", + BinaryOp::Shl => "<<", + BinaryOp::Shr => ">>", + }); + self.write(" "); + + self.print_expr(&bin.right); + + if needs_parens { + self.write(")"); + } + } + + fn print_unary_expr(&mut self, un: &UnaryExpr) { + match un.op { + UnaryOp::Neg => self.write("-"), + UnaryOp::Not => self.write("!"), + UnaryOp::BitNot => self.write("~"), + UnaryOp::AddrOf => self.write("&"), + UnaryOp::Deref => self.write("*"), + UnaryOp::OptionalUnwrap => { + self.print_expr(&un.operand); + self.write(".?"); + return; + } + UnaryOp::ErrorUnwrap => { + self.print_expr(&un.operand); + self.write(".!"); + return; + } + UnaryOp::Try => self.write("try "), + } + self.print_expr(&un.operand); + } + + // ========================================================================= + // Types + // ========================================================================= + + fn print_type_expr(&mut self, ty: &TypeExpr) { + match ty { + TypeExpr::Primitive(p) => self.print_primitive_type(p), + TypeExpr::Qubit => self.write("qubit"), + TypeExpr::Bit => self.write("bit"), + TypeExpr::QAlloc(cap) => { + self.write("qalloc"); + if let Some(c) = cap { + self.write("("); + self.print_expr(c); + self.write(")"); + } + } + TypeExpr::Array(arr) => { + self.write("["); + if let Some(size) = &arr.size { + self.print_expr(size); + } + self.write("]"); + self.print_type_expr(&arr.element); + } + TypeExpr::Pointer(ptr) => { + if ptr.is_many { + self.write("[*"); + } else { + self.write("*"); + } + if ptr.is_const { + self.write("const "); + } + self.print_type_expr(&ptr.pointee); + } + TypeExpr::Optional(inner) => { + self.write("?"); + self.print_type_expr(inner); + } + TypeExpr::ErrorUnion(eu) => { + self.print_type_expr(&eu.error_type); + self.write("!"); + self.print_type_expr(&eu.payload_type); + } + TypeExpr::CollectedErrors(ce) => { + self.write("[]"); + self.print_type_expr(&ce.error_type); + self.write("!"); + self.print_type_expr(&ce.payload_type); + } + TypeExpr::Fn(f) => { + self.write("fn("); + for (i, param) in f.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_type_expr(param); + } + self.write(")"); + if let Some(ret) = &f.return_type { + self.write(" -> "); + self.print_type_expr(ret); + } + } + TypeExpr::Tuple(types) => { + self.write("("); + for (i, t) in types.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_type_expr(t); + } + self.write(")"); + } + TypeExpr::Set(elem) => { + self.write("Set("); + self.print_type_expr(elem); + self.write(")"); + } + TypeExpr::Named(path) => { + for (i, seg) in path.segments.iter().enumerate() { + if i > 0 { + self.write("."); + } + self.write(seg); + } + } + TypeExpr::Type => self.write("type"), + TypeExpr::AnyType => self.write("anytype"), + TypeExpr::Unit => self.write("unit"), + TypeExpr::Struct(s) => { + if s.is_packed { + self.write("packed "); + } + self.write("struct { "); + for (i, field) in s.fields.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&field.name); + self.write(": "); + self.print_type_expr(&field.ty); + } + self.write(" }"); + } + TypeExpr::Enum(e) => { + self.write("enum "); + if let Some(tag) = &e.tag_type { + self.write("("); + self.print_type_expr(tag); + self.write(") "); + } + self.write("{ "); + for (i, variant) in e.variants.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&variant.name); + if let Some(val) = &variant.value { + self.write(" = "); + self.print_expr(val); + } + } + self.write(" }"); + } + } + } + + fn print_primitive_type(&mut self, p: &PrimitiveType) { + match p { + PrimitiveType::UInt { bits } => { + self.write("u"); + self.write(&bits.to_string()); + } + PrimitiveType::IInt { bits } => { + self.write("i"); + self.write(&bits.to_string()); + } + PrimitiveType::Usize => self.write("usize"), + PrimitiveType::Isize => self.write("isize"), + PrimitiveType::F16 => self.write("f16"), + PrimitiveType::F32 => self.write("f32"), + PrimitiveType::F64 => self.write("f64"), + PrimitiveType::F128 => self.write("f128"), + PrimitiveType::A64 => self.write("a64"), + PrimitiveType::Bool => self.write("bool"), + } + } +} + +// ============================================================================= +// String escaping helpers +// ============================================================================= + +fn escape_string(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + match c { + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + c if c.is_control() => { + result.push_str(&format!("\\x{:02x}", c as u32)); + } + c => result.push(c), + } + } + result +} + +fn escape_char(c: char) -> String { + match c { + '\n' => "\\n".to_string(), + '\r' => "\\r".to_string(), + '\t' => "\\t".to_string(), + '\\' => "\\\\".to_string(), + '\'' => "\\'".to_string(), + c if c.is_control() => format!("\\x{:02x}", c as u32), + c => c.to_string(), + } +} + +/// Escape text inside f-strings (also escapes { and }) +fn escape_fstring_text(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + match c { + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + '{' => result.push_str("\\{"), + '}' => result.push_str("\\}"), + c if c.is_control() => { + result.push_str(&format!("\\x{:02x}", c as u32)); + } + c => result.push(c), + } + } + result +} + +// ============================================================================= +// Public API +// ============================================================================= + +/// Format a Zlup program AST to canonical string form. +pub fn pretty_print(program: &Program, options: &PrettyOptions) -> String { + let mut printer = PrettyPrinter::new(options.clone()); + printer.print_program(program) +} + +/// Format Zlup source code using AST-based pretty printing. +/// +/// This parses the source to an AST and pretty-prints it, providing +/// more accurate formatting than text-based approaches. +/// +/// Returns `None` if the source cannot be parsed. +pub fn format_source(source: &str, options: &PrettyOptions) -> Option { + match crate::parser::parse(source) { + Ok(program) => Some(pretty_print(&program, options)), + Err(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse; + + fn format(source: &str) -> String { + let program = parse(source).expect("Failed to parse"); + pretty_print(&program, &PrettyOptions::default()) + } + + #[test] + fn test_simple_function() { + let source = "fn main() -> unit { return unit; }"; + let formatted = format(source); + assert!(formatted.contains("fn main() -> unit")); + assert!(formatted.contains("return;")); + } + + #[test] + fn test_function_with_params() { + let source = "fn add(a: u32, b: u32) -> u32 { return a + b; }"; + let formatted = format(source); + assert!(formatted.contains("fn add(a: u32, b: u32) -> u32")); + } + + #[test] + fn test_binding_with_type() { + let source = "x: u32 = 42;"; + let formatted = format(source); + assert!(formatted.contains("x: u32 = 42;")); + } + + #[test] + fn test_binding_inferred() { + let source = "x := 42;"; + let formatted = format(source); + assert!(formatted.contains("x := 42;")); + } + + #[test] + fn test_mutable_binding() { + let source = "mut x := 42;"; + let formatted = format(source); + assert!(formatted.contains("mut x := 42;")); + } + + #[test] + fn test_if_statement() { + let source = "fn test() -> unit { if (x == 1) { y := 2; } return unit; }"; + let formatted = format(source); + assert!(formatted.contains("if")); + assert!(formatted.contains("x == 1")); + assert!(formatted.contains("y := 2;")); + } + + #[test] + fn test_if_else() { + let source = "fn test() -> unit { if (x) { a := 1; } else { b := 2; } return unit; }"; + let formatted = format(source); + assert!(formatted.contains("if (x)")); + assert!(formatted.contains("else {")); + } + + #[test] + fn test_for_loop() { + let source = "fn test() -> unit { for i in 0..10 { x := i; } }"; + let formatted = format(source); + assert!(formatted.contains("for")); + assert!(formatted.contains("0..10")); + } + + #[test] + fn test_quantum_gate() { + let source = "fn test() -> unit { h q[0]; }"; + let formatted = format(source); + assert!(formatted.contains("h q[0]")); + } + + #[test] + fn test_two_qubit_gate() { + let source = "fn test() -> unit { cx (q[0], q[1]); }"; + let formatted = format(source); + assert!(formatted.contains("cx (q[0], q[1])")); + } + + #[test] + fn test_parameterized_gate() { + let source = "fn test() -> unit { rx(0.5) q[0]; }"; + let formatted = format(source); + assert!(formatted.contains("rx(0.5) q[0]")); + } + + #[test] + fn test_tick_block() { + let source = "fn test() -> unit { tick { h q[0]; cx (q[0], q[1]); } }"; + let formatted = format(source); + assert!(formatted.contains("tick {")); + } + + #[test] + fn test_indentation() { + let source = "fn main() -> unit { if (true) { x := 1; } return unit; }"; + let formatted = format(source); + // Check that nested content is indented - just verify content is present + assert!(formatted.contains("if (true)")); + assert!(formatted.contains("x := 1;")); + } + + #[test] + fn test_binary_operators() { + let source = "fn test() -> unit { x := a + b * c; }"; + let formatted = format(source); + assert!(formatted.contains("a + b * c")); + } + + #[test] + fn test_comparison_operators() { + let source = "fn test() -> unit { if (x == 1) { y := 2; } return unit; }"; + let formatted = format(source); + assert!(formatted.contains("x == 1")); + } + + #[test] + fn test_array_literal() { + let source = "fn test() -> unit { arr := [1, 2, 3]; }"; + let formatted = format(source); + assert!(formatted.contains("[1, 2, 3]")); + } + + #[test] + fn test_tuple() { + let source = "fn test() -> unit { t := (1, 2, 3); }"; + let formatted = format(source); + assert!(formatted.contains("(1, 2, 3)")); + } + + #[test] + fn test_string_literal() { + let source = r#"fn test() -> unit { s := "hello"; }"#; + let formatted = format(source); + assert!(formatted.contains(r#""hello""#)); + } + + #[test] + fn test_string_escapes() { + let source = r#"fn test() -> unit { s := "hello\nworld"; }"#; + let formatted = format(source); + assert!(formatted.contains(r#"\n"#)); + } + + #[test] + fn test_pub_function() { + let source = "pub fn exported() -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("pub fn exported()")); + } + + #[test] + fn test_inline_function() { + let source = "inline fn fast() -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("inline fn fast()")); + } + + #[test] + fn test_field_access() { + // Test field access works correctly + let source = "fn test() -> unit { x := obj.field; return unit; }"; + let formatted = format(source); + assert!(formatted.contains("obj.field")); + } + + #[test] + fn test_custom_indent() { + let source = "fn main() -> unit { x := 1; }"; + let options = PrettyOptions { + indent_size: 2, + ..Default::default() + }; + let program = parse(source).unwrap(); + let formatted = pretty_print(&program, &options); + assert!(formatted.contains(" x := 1;")); // 2 spaces + } + + #[test] + fn test_tabs() { + let source = "fn main() -> unit { x := 1; }"; + let options = PrettyOptions { + use_spaces: false, + ..Default::default() + }; + let program = parse(source).unwrap(); + let formatted = pretty_print(&program, &options); + assert!(formatted.contains("\tx := 1;")); // tab + } + + #[test] + fn test_trailing_newline() { + let source = "fn main() -> unit { }"; + let formatted = format(source); + assert!(formatted.ends_with('\n')); + } + + #[test] + fn test_empty_block() { + let source = "fn empty() -> unit {}"; + let formatted = format(source); + assert!(formatted.contains("{}")); + } + + #[test] + fn test_multiple_functions() { + let source = "fn a() -> unit { } fn b() -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("fn a()")); + assert!(formatted.contains("fn b()")); + } + + #[test] + fn test_extern_fn() { + let source = r#"extern "C" fn puts(s: [*]const u8) -> i32;"#; + let formatted = format(source); + assert!(formatted.contains(r#"extern "C" fn puts"#)); + } + + #[test] + fn test_builtin_call() { + let source = "fn test() -> unit { x := @sizeOf(u32); }"; + let formatted = format(source); + assert!(formatted.contains("@sizeOf(u32)")); + } + + #[test] + fn test_return_statement() { + let source = "fn test() -> u32 { return 42; }"; + let formatted = format(source); + assert!(formatted.contains("return 42;")); + } + + #[test] + fn test_break_continue() { + let source = "fn test() -> unit { for i in 0..10 { break; } return unit; }"; + let formatted = format(source); + assert!(formatted.contains("break;")); + } + + #[test] + fn test_deeply_nested() { + let source = "fn main() -> unit { if (a) { if (b) { if (c) { x := 1; } return unit; } return unit; } return unit; }"; + let formatted = format(source); + // Should have proper nesting - just check content is preserved + assert!(formatted.contains("if (a)")); + assert!(formatted.contains("if (b)")); + assert!(formatted.contains("if (c)")); + assert!(formatted.contains("x := 1;")); + } + + #[test] + fn test_measurement() { + let source = "fn test() -> unit { r := mz(u8) q; }"; + let formatted = format(source); + assert!(formatted.contains("mz(u8) q")); + } + + #[test] + fn test_optional_type() { + let source = "fn test(x: ?u32) -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("?u32")); + } + + #[test] + fn test_pointer_type() { + let source = "fn test(p: *u32) -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("*u32")); + } + + #[test] + fn test_array_type() { + let source = "fn test(arr: [10]u32) -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("[10]u32")); + } + + #[test] + fn test_format_source_returns_none_on_invalid() { + let result = format_source("fn broken(", &PrettyOptions::default()); + assert!(result.is_none()); + } + + #[test] + fn test_format_source_works_on_valid() { + let result = format_source("fn main() -> unit {}", &PrettyOptions::default()); + assert!(result.is_some()); + assert!(result.unwrap().contains("fn main()")); + } +} diff --git a/exp/zlup/src/rational.rs b/exp/zlup/src/rational.rs new file mode 100644 index 000000000..2aa4c43f9 --- /dev/null +++ b/exp/zlup/src/rational.rs @@ -0,0 +1,863 @@ +//! Rational number type for exact fraction representation. +//! +//! This module provides a `Rational` type that represents fractions exactly, +//! avoiding floating-point precision issues. This is particularly important +//! for angle calculations in quantum computing where angles like 1/4 turn +//! (pi/2 radians) must be exact. +//! +//! # Examples +//! +//! ``` +//! use zlup::rational::Rational; +//! +//! let quarter = Rational::new(1, 4); +//! let half = Rational::new(1, 2); +//! assert_eq!(quarter + quarter, half); +//! ``` + +use std::cmp::Ordering; +use std::fmt; +use std::ops::{Add, Div, Mul, Neg, Sub}; + +/// A rational number represented as numerator/denominator. +/// +/// Rationals are always stored in lowest terms with a positive denominator. +#[derive(Clone, Copy, Eq, PartialEq, Hash)] +pub struct Rational { + /// Numerator (can be negative) + num: i64, + /// Denominator (always positive, never zero) + den: u64, +} + +impl Rational { + /// Create a new rational number, automatically reducing to lowest terms. + pub fn new(numerator: i64, denominator: i64) -> Self { + if denominator == 0 { + panic!("Rational denominator cannot be zero"); + } + + // Normalize sign: denominator is always positive + let (num, den) = if denominator < 0 { + (-numerator, (-denominator) as u64) + } else { + (numerator, denominator as u64) + }; + + // Reduce to lowest terms + let g = gcd(num.unsigned_abs(), den); + Self { + num: num / g as i64, + den: den / g, + } + } + + /// Create a rational from an integer. + pub fn from_int(n: i64) -> Self { + Self { num: n, den: 1 } + } + + /// Create zero. + pub const ZERO: Rational = Rational { num: 0, den: 1 }; + + /// Create one. + pub const ONE: Rational = Rational { num: 1, den: 1 }; + + /// Create one half. + pub const HALF: Rational = Rational { num: 1, den: 2 }; + + /// Create one quarter. + pub const QUARTER: Rational = Rational { num: 1, den: 4 }; + + /// Create one eighth. + pub const EIGHTH: Rational = Rational { num: 1, den: 8 }; + + /// Get the numerator. + pub fn numerator(&self) -> i64 { + self.num + } + + /// Get the denominator. + pub fn denominator(&self) -> u64 { + self.den + } + + /// Check if this is zero. + pub fn is_zero(&self) -> bool { + self.num == 0 + } + + /// Check if this is an integer. + pub fn is_integer(&self) -> bool { + self.den == 1 + } + + /// Convert to an integer if exact, otherwise None. + pub fn to_integer(&self) -> Option { + if self.den == 1 { Some(self.num) } else { None } + } + + /// Convert to f64. + pub fn to_f64(&self) -> f64 { + self.num as f64 / self.den as f64 + } + + /// Try to convert an f64 to a rational. + /// + /// Uses continued fraction approximation to find a rational with + /// denominator up to `max_denominator` that approximates the float. + pub fn from_f64(value: f64, max_denominator: u64) -> Option { + if !value.is_finite() { + return None; + } + + // Handle negative values + if value < 0.0 { + return Self::from_f64(-value, max_denominator).map(|r| -r); + } + + // Handle zero + if value == 0.0 { + return Some(Self::ZERO); + } + + // Handle integers + if value == value.floor() && value.abs() < i64::MAX as f64 { + return Some(Self::from_int(value as i64)); + } + + // Continued fraction approximation + let mut x = value; + let a0 = x.floor() as i64; + + // Build convergents + let mut h_prev: i64 = 1; + let mut k_prev: u64 = 0; + let mut h_curr: i64 = a0; + let mut k_curr: u64 = 1; + + const MAX_ITERATIONS: usize = 50; + const TOLERANCE: f64 = 1e-15; + + for _ in 0..MAX_ITERATIONS { + let frac = x - x.floor(); + if frac.abs() < TOLERANCE { + break; + } + + x = 1.0 / frac; + let a = x.floor() as i64; + + // Compute next convergent + let h_next = a.saturating_mul(h_curr).saturating_add(h_prev); + let k_next = (a as u64).saturating_mul(k_curr).saturating_add(k_prev); + + if k_next > max_denominator { + break; + } + + h_prev = h_curr; + k_prev = k_curr; + h_curr = h_next; + k_curr = k_next; + + // Check if we've converged + let approx = h_curr as f64 / k_curr as f64; + if (approx - value).abs() < TOLERANCE { + break; + } + } + + Some(Self::new(h_curr, k_curr as i64)) + } + + /// Try to recognize a common fraction from a float value. + /// + /// Returns Some if the value is very close to a common fraction + /// like 1/2, 1/4, 1/8, 1/3, etc. + pub fn from_f64_common(value: f64) -> Option { + const TOLERANCE: f64 = 1e-12; + + // Common fractions to check + static COMMON: &[(f64, i64, u64)] = &[ + (0.0, 0, 1), + (1.0, 1, 1), + (0.5, 1, 2), + (0.25, 1, 4), + (0.125, 1, 8), + (0.0625, 1, 16), + (0.75, 3, 4), + (0.375, 3, 8), + (0.625, 5, 8), + (0.875, 7, 8), + // Thirds + (1.0 / 3.0, 1, 3), + (2.0 / 3.0, 2, 3), + // Sixths + (1.0 / 6.0, 1, 6), + (5.0 / 6.0, 5, 6), + // Twelfths + (1.0 / 12.0, 1, 12), + (5.0 / 12.0, 5, 12), + (7.0 / 12.0, 7, 12), + (11.0 / 12.0, 11, 12), + ]; + + // Handle negative + let (abs_value, sign) = if value < 0.0 { + (-value, -1i64) + } else { + (value, 1i64) + }; + + // Check for common fractions + for &(frac_val, num, den) in COMMON { + if (abs_value - frac_val).abs() < TOLERANCE { + return Some(Self { + num: sign * num, + den, + }); + } + } + + // Check for fractions with small denominators (1-16) + for den in 1u64..=16 { + let num = (abs_value * den as f64).round() as i64; + if num >= 0 { + let approx = num as f64 / den as f64; + if (approx - abs_value).abs() < TOLERANCE { + return Some(Self::new(sign * num, den as i64)); + } + } + } + + None + } + + /// Convert an f64 to its exact rational representation. + /// + /// Every IEEE 754 float is exactly representable as a rational number + /// (specifically, a dyadic rational with power-of-2 denominator). + /// This is similar to Python's `fractions.Fraction.from_float()`. + /// + /// Note: The resulting rational may have a very large denominator. + /// Use `limit_denominator()` to find a simpler approximation. + /// + /// # Examples + /// + /// ``` + /// use zlup::rational::Rational; + /// + /// // Exact representations + /// assert_eq!(Rational::from_f64_exact(0.5), Some(Rational::new(1, 2))); + /// assert_eq!(Rational::from_f64_exact(0.25), Some(Rational::new(1, 4))); + /// + /// // 0.1 is not exactly representable in binary, so we get the exact + /// // IEEE 754 representation as a rational + /// let r = Rational::from_f64_exact(0.1).unwrap(); + /// assert_eq!(r.to_f64(), 0.1); // Round-trips exactly + /// ``` + pub fn from_f64_exact(value: f64) -> Option { + if !value.is_finite() { + return None; + } + + if value == 0.0 { + return Some(Self::ZERO); + } + + // Handle negative values + let (abs_value, sign) = if value < 0.0 { + (-value, -1i64) + } else { + (value, 1i64) + }; + + // Decompose the float into mantissa and exponent + // f64 = mantissa * 2^exponent where mantissa is in [1, 2) + // But we want the integer mantissa representation + let bits = abs_value.to_bits(); + let exponent_bits = ((bits >> 52) & 0x7FF) as i32; + let mantissa_bits = bits & 0x000F_FFFF_FFFF_FFFF; + + if exponent_bits == 0 { + // Subnormal number + // Value = mantissa_bits * 2^(-1022 - 52) + let num = sign * (mantissa_bits as i64); + let exp = 1022 + 52; + // Denominator is 2^exp, which is huge for subnormals + // We'll simplify by dividing out common factors of 2 + return Self::from_mantissa_exp(num, exp); + } + + // Normal number + // The implicit leading 1 bit: mantissa = 1.mantissa_bits + // So integer mantissa = (1 << 52) | mantissa_bits + let int_mantissa = (1u64 << 52) | mantissa_bits; + let exponent = exponent_bits - 1023 - 52; // Subtract bias and mantissa bits + + let num = sign * (int_mantissa as i64); + + if exponent >= 0 { + // Value = mantissa * 2^exponent (integer result) + if exponent < 63 { + Some(Self::from_int(num << exponent)) + } else { + // Too large, would overflow + None + } + } else { + // Value = mantissa / 2^(-exponent) + Self::from_mantissa_exp(num, (-exponent) as u32) + } + } + + /// Helper: create rational from mantissa / 2^exp, reducing common factors + fn from_mantissa_exp(mantissa: i64, exp: u32) -> Option { + if mantissa == 0 { + return Some(Self::ZERO); + } + + // Count trailing zeros in mantissa to reduce the fraction + let trailing_zeros = (mantissa.unsigned_abs()).trailing_zeros(); + let reduced_mantissa = mantissa >> trailing_zeros; + let reduced_exp = exp.saturating_sub(trailing_zeros); + + if reduced_exp > 62 { + // Denominator would overflow u64 + return None; + } + + let denominator = 1u64 << reduced_exp; + Some(Self { + num: reduced_mantissa, + den: denominator, + }) + } + + /// Find the closest rational with denominator at most `max_denominator`. + /// + /// Similar to Python's `Fraction.limit_denominator()`. Useful for + /// simplifying exact float conversions to human-readable fractions. + /// + /// # Examples + /// + /// ``` + /// use zlup::rational::Rational; + /// + /// // The exact representation of 0.1 has a huge denominator + /// let exact = Rational::from_f64_exact(0.1).unwrap(); + /// + /// // Limit to denominator <= 10 gives us 1/10 + /// let simple = exact.limit_denominator(10); + /// assert_eq!(simple, Rational::new(1, 10)); + /// ``` + pub fn limit_denominator(&self, max_denominator: u64) -> Self { + if self.den <= max_denominator { + return *self; + } + + // Use continued fraction algorithm to find best approximation + // This is the standard algorithm from Python's fractions module + let mut p0: i64 = 0; + let mut q0: u64 = 1; + let mut p1: i64 = 1; + let mut q1: u64 = 0; + + let mut n = self.num.abs(); + let mut d = self.den; + + loop { + let a = n / d as i64; + let q2 = q0 + (a as u64) * q1; + + if q2 > max_denominator { + break; + } + + let p2 = p0 + a * p1; + p0 = p1; + q0 = q1; + p1 = p2; + q1 = q2; + + let new_n = d as i64; + d = (n % d as i64) as u64; + n = new_n; + + if d == 0 { + break; + } + } + + // Choose between p1/q1 and the mediant + let k = (max_denominator - q0) / q1; + let bound1 = Self::new(p0 + (k as i64) * p1, (q0 + k * q1) as i64); + let bound2 = Self::new(p1, q1 as i64); + + let abs_self = self.abs(); + let diff1 = (abs_self - bound1).abs(); + let diff2 = (abs_self - bound2).abs(); + + let result = if diff1 <= diff2 { bound1 } else { bound2 }; + + if self.num < 0 { -result } else { result } + } + + /// Smart float-to-rational conversion. + /// + /// Tries multiple strategies in order of preference: + /// 1. Exact integer check + /// 2. Common fractions (1/2, 1/3, 1/4, etc.) + /// 3. Exact IEEE 754 conversion, limited to reasonable denominator + /// 4. Continued fraction approximation + /// + /// This is the recommended method for converting floats to rationals. + pub fn from_f64_best(value: f64, max_denominator: u64) -> Option { + if !value.is_finite() { + return None; + } + + // 1. Check for zero + if value == 0.0 { + return Some(Self::ZERO); + } + + // 2. Check for exact integer + if value == value.floor() && value.abs() < i64::MAX as f64 { + return Some(Self::from_int(value as i64)); + } + + // 3. Check for common fractions (most quantum angles) + if let Some(r) = Self::from_f64_common(value) + && r.den <= max_denominator + { + return Some(r); + } + + // 4. Try exact conversion with limit + if let Some(exact) = Self::from_f64_exact(value) { + let limited = exact.limit_denominator(max_denominator); + // Verify it's a good approximation + if (limited.to_f64() - value).abs() < 1e-15 * value.abs().max(1.0) { + return Some(limited); + } + } + + // 5. Fall back to continued fraction approximation + Self::from_f64(value, max_denominator) + } + + /// Return the absolute value. + pub fn abs(&self) -> Self { + Self { + num: self.num.abs(), + den: self.den, + } + } + + /// Return the reciprocal (1/self). + pub fn recip(&self) -> Self { + if self.num == 0 { + panic!("Cannot take reciprocal of zero"); + } + if self.num > 0 { + Self { + num: self.den as i64, + den: self.num as u64, + } + } else { + Self { + num: -(self.den as i64), + den: (-self.num) as u64, + } + } + } + + /// Try to recognize a float value as a rational multiple of pi. + /// + /// If `value ≈ (n/d) * pi`, returns `Some((n, d))`. + /// This is useful for converting radians to turns while preserving precision. + pub fn from_f64_pi_multiple(value: f64) -> Option<(i64, u64)> { + use std::f64::consts::PI; + const TOLERANCE: f64 = 1e-12; + + if !value.is_finite() { + return None; + } + + let (abs_value, sign) = if value < 0.0 { + (-value, -1i64) + } else { + (value, 1i64) + }; + + // Try to express as (n/d) * pi for small denominators + for d in 1u64..=16 { + let n_float = abs_value * d as f64 / PI; + let n = n_float.round() as i64; + + if n > 0 { + let expected = n as f64 * PI / d as f64; + if (expected - abs_value).abs() < TOLERANCE * abs_value.max(1.0) { + let r = Self::new(sign * n, d as i64); + return Some((r.num, r.den)); + } + } + } + + None + } + + /// Convert a radian value (as a float) to turns as a Rational. + /// + /// Detects if the radian value is a rational multiple of pi and preserves + /// that precision. If `radians = (n/d) * pi`, then `turns = n / (2*d)`. + pub fn radians_to_turns(radians: f64) -> Option { + // First try to detect if this is a rational multiple of pi + if let Some((n, d)) = Self::from_f64_pi_multiple(radians) { + // (n/d) * pi radians = n / (2*d) turns + return Some(Self::new(n, 2 * d as i64)); + } + + // Fall back to direct conversion + let turns = radians / (2.0 * std::f64::consts::PI); + Self::from_f64_best(turns, 1000) + } + + /// Convert turns (as a Rational) to radians as a float. + pub fn turns_to_radians(&self) -> f64 { + self.to_f64() * 2.0 * std::f64::consts::PI + } +} + +impl fmt::Debug for Rational { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.den == 1 { + write!(f, "Rational({})", self.num) + } else { + write!(f, "Rational({}/{})", self.num, self.den) + } + } +} + +impl fmt::Display for Rational { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.den == 1 { + write!(f, "{}", self.num) + } else { + write!(f, "{}/{}", self.num, self.den) + } + } +} + +impl Default for Rational { + fn default() -> Self { + Self::ZERO + } +} + +impl From for Rational { + fn from(n: i64) -> Self { + Self::from_int(n) + } +} + +impl From for Rational { + fn from(n: i32) -> Self { + Self::from_int(n as i64) + } +} + +impl PartialOrd for Rational { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Rational { + fn cmp(&self, other: &Self) -> Ordering { + // a/b compared to c/d: compare a*d to c*b + let lhs = self.num as i128 * other.den as i128; + let rhs = other.num as i128 * self.den as i128; + lhs.cmp(&rhs) + } +} + +impl Add for Rational { + type Output = Self; + + fn add(self, other: Self) -> Self { + // a/b + c/d = (a*d + c*b) / (b*d) + let num = self.num as i128 * other.den as i128 + other.num as i128 * self.den as i128; + let den = self.den as i128 * other.den as i128; + Self::new(num as i64, den as i64) + } +} + +impl Sub for Rational { + type Output = Self; + + fn sub(self, other: Self) -> Self { + self + (-other) + } +} + +impl Mul for Rational { + type Output = Self; + + fn mul(self, other: Self) -> Self { + // a/b * c/d = (a*c) / (b*d) + // Cross-reduce first to avoid overflow + let g1 = gcd(self.num.unsigned_abs(), other.den); + let g2 = gcd(other.num.unsigned_abs(), self.den); + + let num = (self.num / g1 as i64) * (other.num / g2 as i64); + let den = (self.den / g2) * (other.den / g1); + + Self { num, den } + } +} + +impl Div for Rational { + type Output = Self; + + // Using multiplication by reciprocal is the standard way to implement + // division for rationals: a/b ÷ c/d = a/b × d/c + #[allow(clippy::suspicious_arithmetic_impl)] + fn div(self, other: Self) -> Self { + self * other.recip() + } +} + +impl Neg for Rational { + type Output = Self; + + fn neg(self) -> Self { + Self { + num: -self.num, + den: self.den, + } + } +} + +/// Greatest common divisor using Euclidean algorithm. +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + let t = b; + b = a % b; + a = t; + } + a.max(1) // Ensure we never return 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_reduces() { + let r = Rational::new(2, 4); + assert_eq!(r.numerator(), 1); + assert_eq!(r.denominator(), 2); + } + + #[test] + fn test_negative_denominator() { + let r = Rational::new(1, -2); + assert_eq!(r.numerator(), -1); + assert_eq!(r.denominator(), 2); + } + + #[test] + fn test_add() { + let a = Rational::new(1, 4); + let b = Rational::new(1, 4); + assert_eq!(a + b, Rational::new(1, 2)); + } + + #[test] + fn test_sub() { + let a = Rational::new(1, 2); + let b = Rational::new(1, 4); + assert_eq!(a - b, Rational::new(1, 4)); + } + + #[test] + fn test_mul() { + let a = Rational::new(2, 3); + let b = Rational::new(3, 4); + assert_eq!(a * b, Rational::new(1, 2)); + } + + #[test] + fn test_div() { + let a = Rational::new(1, 2); + let b = Rational::new(1, 4); + assert_eq!(a / b, Rational::new(2, 1)); + } + + #[test] + fn test_to_f64() { + let r = Rational::new(1, 4); + assert_eq!(r.to_f64(), 0.25); + } + + #[test] + fn test_from_f64_common() { + assert_eq!(Rational::from_f64_common(0.25), Some(Rational::new(1, 4))); + assert_eq!(Rational::from_f64_common(0.125), Some(Rational::new(1, 8))); + assert_eq!(Rational::from_f64_common(0.5), Some(Rational::new(1, 2))); + assert_eq!( + Rational::from_f64_common(1.0 / 3.0), + Some(Rational::new(1, 3)) + ); + } + + #[test] + fn test_from_f64_approximation() { + let r = Rational::from_f64(0.333333333, 100).unwrap(); + assert_eq!(r, Rational::new(1, 3)); + } + + #[test] + fn test_comparison() { + let a = Rational::new(1, 3); + let b = Rational::new(1, 4); + assert!(a > b); + } + + #[test] + fn test_display() { + assert_eq!(format!("{}", Rational::new(1, 4)), "1/4"); + assert_eq!(format!("{}", Rational::new(3, 1)), "3"); + } + + #[test] + fn test_from_f64_exact_dyadic() { + // Dyadic fractions (power of 2 denominators) are exactly representable + assert_eq!(Rational::from_f64_exact(0.5), Some(Rational::new(1, 2))); + assert_eq!(Rational::from_f64_exact(0.25), Some(Rational::new(1, 4))); + assert_eq!(Rational::from_f64_exact(0.125), Some(Rational::new(1, 8))); + assert_eq!(Rational::from_f64_exact(0.0625), Some(Rational::new(1, 16))); + assert_eq!(Rational::from_f64_exact(0.75), Some(Rational::new(3, 4))); + assert_eq!(Rational::from_f64_exact(0.375), Some(Rational::new(3, 8))); + } + + #[test] + fn test_from_f64_exact_roundtrip() { + // Any float should round-trip through exact conversion. The values are + // deliberately arbitrary (not math constants) - the point is exact + // dyadic round-tripping, not the specific numbers. + let values = [0.1, 0.2, 0.3, 0.7, 1.1, 3.65432, 0.123456789]; + for &v in &values { + let r = Rational::from_f64_exact(v).unwrap(); + assert_eq!(r.to_f64(), v, "Round-trip failed for {}", v); + } + } + + #[test] + fn test_from_f64_exact_negative() { + assert_eq!(Rational::from_f64_exact(-0.5), Some(Rational::new(-1, 2))); + assert_eq!(Rational::from_f64_exact(-0.25), Some(Rational::new(-1, 4))); + } + + #[test] + fn test_from_f64_exact_special() { + assert_eq!(Rational::from_f64_exact(0.0), Some(Rational::ZERO)); + assert_eq!(Rational::from_f64_exact(f64::NAN), None); + assert_eq!(Rational::from_f64_exact(f64::INFINITY), None); + assert_eq!(Rational::from_f64_exact(f64::NEG_INFINITY), None); + } + + #[test] + fn test_limit_denominator() { + // 0.1 exact representation has large denominator + let exact = Rational::from_f64_exact(0.1).unwrap(); + assert!( + exact.denominator() > 10, + "0.1 exact should have large denominator" + ); + + // Limit to 10 should give 1/10 + let limited = exact.limit_denominator(10); + assert_eq!(limited, Rational::new(1, 10)); + + // Verify it's close enough + assert!((limited.to_f64() - 0.1).abs() < 1e-10); + } + + #[test] + fn test_limit_denominator_already_small() { + let r = Rational::new(1, 4); + let limited = r.limit_denominator(100); + assert_eq!(limited, Rational::new(1, 4)); + } + + #[test] + fn test_limit_denominator_pi_approximations() { + // Pi ≈ 3.14159... + let pi_exact = Rational::from_f64_exact(std::f64::consts::PI).unwrap(); + + // Famous approximations: + // 22/7 ≈ 3.142857 (denominator 7) + let approx_7 = pi_exact.limit_denominator(10); + assert_eq!(approx_7, Rational::new(22, 7)); + + // 333/106 ≈ 3.141509 (denominator 106) + let approx_1000 = pi_exact.limit_denominator(1000); + assert!((approx_1000.to_f64() - std::f64::consts::PI).abs() < 0.0001); + } + + #[test] + fn test_from_f64_best() { + // Common fractions should be recognized + assert_eq!( + Rational::from_f64_best(0.25, 100), + Some(Rational::new(1, 4)) + ); + assert_eq!(Rational::from_f64_best(0.5, 100), Some(Rational::new(1, 2))); + assert_eq!( + Rational::from_f64_best(1.0 / 3.0, 100), + Some(Rational::new(1, 3)) + ); + + // Integers + assert_eq!(Rational::from_f64_best(5.0, 100), Some(Rational::new(5, 1))); + + // Arbitrary value should get best approximation + let r = Rational::from_f64_best(0.1, 100).unwrap(); + assert_eq!(r, Rational::new(1, 10)); + } + + #[test] + fn test_quantum_angle_fractions() { + // Common quantum computing angles as fractions of a turn + // T-gate: 1/8 turn + assert_eq!( + Rational::from_f64_best(0.125, 100), + Some(Rational::new(1, 8)) + ); + + // S-gate: 1/4 turn + assert_eq!( + Rational::from_f64_best(0.25, 100), + Some(Rational::new(1, 4)) + ); + + // Z-gate: 1/2 turn + assert_eq!(Rational::from_f64_best(0.5, 100), Some(Rational::new(1, 2))); + + // T-dagger: 7/8 turn + assert_eq!( + Rational::from_f64_best(0.875, 100), + Some(Rational::new(7, 8)) + ); + + // S-dagger: 3/4 turn + assert_eq!( + Rational::from_f64_best(0.75, 100), + Some(Rational::new(3, 4)) + ); + } +} diff --git a/exp/zlup/src/semantic.rs b/exp/zlup/src/semantic.rs new file mode 100644 index 000000000..4a26e55e3 --- /dev/null +++ b/exp/zlup/src/semantic.rs @@ -0,0 +1,9675 @@ +//! Semantic analysis for Zluppy programs. +//! +//! This module performs: +//! - Name resolution and scope management +//! - Type checking and inference +//! - Comptime evaluation +//! - Quantum resource validation (for HUGR codegen) +//! - **Qubit state tracking** (prepared/unprepared lifecycle) +//! - **Allocator capacity validation** (bounds checking) +//! - **Loop bound checking** (NASA Power of 10 compliance) +//! +//! ## Qubit State Tracking +//! +//! Zluppy tracks qubit states at compile time. Every qubit slot has exactly +//! two states: +//! +//! ```text +//! ┌────────────┐ prepare() ┌──────────┐ +//! │ unprepared │ ──────────> │ prepared │ +//! └────────────┘ └──────────┘ +//! ^ │ +//! │ measure() │ +//! └──────────────────────────┘ +//! ``` +//! +//! - **unprepared**: Initial state, or after measurement +//! - **prepared**: Ready for gate operations +//! +//! Gates on unprepared qubits are compile-time errors. This is the allocator +//! model's answer to Guppy's linear types - simpler, explicit, same safety. +//! +//! The semantic analysis produces a typed AST and symbol table that +//! can be used by code generators (HUGR, SLR-AST, etc.). + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use thiserror::Error; + +use crate::ast::{ + self, BinaryOp, Binding, Block, ElseBranch, Expr, FStringPart, FnDecl, ForRange, PrimitiveType, + Program, SourceLocation, Stmt, StructDecl, TopLevelDecl, TypeExpr, UnaryOp, +}; +use crate::comptime::{ComptimeEvaluator, ComptimeValue}; +use crate::module::{ExportedSymbol, ModuleLoader}; + +// ============================================================================= +// Semantic Errors +// ============================================================================= + +/// Semantic analysis errors. +#[derive(Debug, Clone, Error)] +pub enum SemanticError { + #[error("undefined symbol '{name}'")] + UndefinedSymbol { + name: String, + location: SourceLocation, + }, + + #[error("symbol '{name}' already defined")] + DuplicateSymbol { + name: String, + location: SourceLocation, + }, + + #[error("type mismatch: expected {expected}, found {found}")] + TypeMismatch { + expected: String, + found: String, + location: SourceLocation, + }, + + #[error("cannot infer type for '{name}'")] + CannotInferType { + name: String, + location: SourceLocation, + }, + + #[error( + "empty array literal requires explicit type annotation: use `[]: [0]T` or provide elements" + )] + EmptyArrayNeedsType { location: SourceLocation }, + + #[error( + "empty set literal requires explicit type annotation: use `set{{}} as Set(T)` or provide elements" + )] + EmptySetNeedsType { location: SourceLocation }, + + #[error("invalid integer bit width {bits}: must be between 1 and 128")] + InvalidBitWidth { bits: u16, location: SourceLocation }, + + #[error("gate '{gate}' requires {expected} qubits, got {found}")] + GateArityMismatch { + gate: String, + expected: usize, + found: usize, + location: SourceLocation, + }, + + #[error( + "ambiguous target for multi-qubit gate '{gate}': use explicit qubit pairs like '{gate} (q[0], q[1])' or batch '{gate} {{(q[0], q[1]), ...}}'" + )] + AmbiguousGateTarget { + gate: String, + location: SourceLocation, + }, + + #[error("invalid gate syntax: use '{gate} {hint}' instead of '{gate}(...)'")] + InvalidGateSyntax { + gate: String, + hint: String, + location: SourceLocation, + }, + + #[error("invalid qubit reference")] + InvalidQubitRef { location: SourceLocation }, + + #[error("allocator '{name}' not found")] + AllocatorNotFound { + name: String, + location: SourceLocation, + }, + + #[error("comptime evaluation failed: {message}")] + ComptimeError { + message: String, + location: SourceLocation, + }, + + #[error("function '{name}' not found")] + FunctionNotFound { + name: String, + location: SourceLocation, + }, + + #[error("cannot call non-function type")] + NotCallable { location: SourceLocation }, + + #[error("wrong number of arguments: expected {expected}, got {found}")] + ArgumentCountMismatch { + expected: usize, + found: usize, + location: SourceLocation, + }, + + #[error("{message}")] + Other { message: String }, + + #[error("module error: {message}")] + ModuleError { + message: String, + location: SourceLocation, + }, + + // ========================================================================= + // Qubit State Errors (Zluppy-specific safety) + // ========================================================================= + #[error("qubit '{allocator}[{index}]' is not prepared - call prepare() first")] + QubitNotPrepared { + allocator: String, + index: usize, + location: SourceLocation, + }, + + #[error("qubit '{allocator}[{index}]' is already prepared")] + QubitAlreadyPrepared { + allocator: String, + index: usize, + location: SourceLocation, + }, + + #[error("qubit index {index} out of bounds for allocator '{allocator}' (capacity: {capacity})")] + QubitIndexOutOfBounds { + allocator: String, + index: usize, + capacity: usize, + location: SourceLocation, + }, + + #[error("array index {index} out of bounds for array of size {size}")] + ArrayIndexOutOfBounds { + index: usize, + size: u64, + location: SourceLocation, + }, + + #[error( + "cannot call .child() on immutable allocator '{name}' - declare with 'mut' to partition: mut {name} := qalloc(...)" + )] + ChildRequiresMutableParent { + name: String, + location: SourceLocation, + }, + + #[error( + "cannot assign to immutable variable '{name}' - declare with 'mut' to allow modification: mut {name} := ..." + )] + ImmutableAssignment { + name: String, + location: SourceLocation, + }, + + // ========================================================================= + // NASA Power of 10 Errors + // ========================================================================= + #[error("unbounded loop detected - use bounded 'for' loops instead")] + UnboundedLoop { location: SourceLocation }, + + #[error("loop bound too large ({bound}) - maximum allowed is {max}")] + LoopBoundTooLarge { + bound: usize, + max: usize, + location: SourceLocation, + }, + + #[error("recursion detected in function '{name}' - recursion is not allowed")] + RecursionDetected { + name: String, + location: SourceLocation, + }, + + #[error("invalid measurement type '{ty}' - expected u1, u8, u64, []u1, []u8, or []u64")] + InvalidMeasurementType { + ty: String, + location: SourceLocation, + }, + + #[error("measurement requires type and target arguments")] + MeasurementMissingArgs { location: SourceLocation }, + + #[error("deprecated measurement syntax: use 'mz(T) target' instead of 'mz(T, target)'")] + DeprecatedMeasurementSyntax { location: SourceLocation }, + + #[error("deprecated syntax: use '{new}' instead of '{old}'")] + DeprecatedSyntax { + old: String, + new: String, + location: SourceLocation, + }, + + #[error( + "measurement type mismatch: declared [{declared}]{element} but measuring {actual} qubit(s)" + )] + MeasurementSizeMismatch { + declared: String, + element: String, + actual: usize, + location: SourceLocation, + }, + + #[error("single qubit measurement requires scalar type (e.g., 'mz(u1) q[0]'), not array type")] + MeasurementScalarExpected { location: SourceLocation }, + + #[error("multiple qubit measurement requires array type (e.g., 'mz([2]u1) [q[0], q[1]]')")] + MeasurementArrayExpected { location: SourceLocation }, + + #[error("pack mode: type {ty} has {capacity} bits but measuring {qubits} qubit(s)")] + MeasurementPackCapacity { + ty: String, + capacity: usize, + qubits: usize, + location: SourceLocation, + }, + + #[error( + "pack mode requires compile-time verifiable type size, but '{ty}' has unknown bit capacity" + )] + MeasurementPackUnknownSize { + ty: String, + location: SourceLocation, + }, + + #[error( + "qubit '{allocator}[{index}]' used multiple times within tick block - parallel operations cannot target the same qubit" + )] + DuplicateQubitInTick { + allocator: String, + index: usize, + location: SourceLocation, + }, + + #[error("nested tick blocks are not allowed - a tick is an atomic time slice")] + NestedTick { location: SourceLocation }, + + #[error("duplicate qubit in measurement: {allocator}[{index}]")] + DuplicateQubitInMeasurement { + allocator: String, + index: usize, + location: SourceLocation, + }, + + #[error("{keyword} outside of loop")] + BreakContinueOutsideLoop { + keyword: String, + location: SourceLocation, + }, + + #[error( + "inline for range must be comptime-evaluable, but '{expr}' cannot be evaluated at compile time" + )] + InlineForRangeNotComptime { + expr: String, + location: SourceLocation, + }, + + #[error("'break' is not allowed in inline for loops - inline for is unrolled at compile time")] + BreakInInlineFor { location: SourceLocation }, + + #[error( + "'continue' is not allowed in inline for loops - inline for is unrolled at compile time" + )] + ContinueInInlineFor { location: SourceLocation }, + + #[error( + "alias '{}' overlaps with existing alias '{}' on source '{}'", + .0.new_alias, .0.existing_alias, .0.source_var + )] + // Boxed because this is the only variant whose inline payload (four `String`s + // plus a `SourceLocation`) pushes `SemanticError` over clippy's 128-byte + // `result_large_err` threshold; boxing keeps the enum (and every + // `SemanticResult`) small. + OverlappingAlias(Box), + + #[error("alias source must be a slice expression (e.g., arr[0..4]), found '{found}'")] + AliasSourceNotSlice { + found: String, + location: SourceLocation, + }, + + #[error( + "alias range must be comptime-evaluable for overlap checking, but '{expr}' cannot be evaluated at compile time" + )] + AliasRangeNotComptime { + expr: String, + location: SourceLocation, + }, + + #[error( + "missing return statement in function '{name}' - all code paths must have explicit returns (use 'return unit;' for unit functions)" + )] + MissingReturn { + name: String, + location: SourceLocation, + }, + + #[error( + "'return;' without a value is only allowed in functions that return unit, but this function returns '{expected}'" + )] + ReturnWithoutValue { + expected: String, + location: SourceLocation, + }, + + #[error("'catch' can only be used on error union types (T!E), found '{found}'")] + CatchOnNonErrorType { + found: String, + location: SourceLocation, + }, + + #[error("undefined type '{name}'")] + UndefinedType { + name: String, + location: SourceLocation, + }, + + #[error("type could not be fully resolved: '{ty}' contains unresolved type variables")] + UnresolvedType { + ty: String, + context: String, + location: SourceLocation, + }, + + #[error("symbol table limit exceeded: {count} symbols exceeds maximum of {max}")] + SymbolTableLimitExceeded { count: usize, max: usize }, + + #[error("scope nesting limit exceeded: {depth} levels exceeds maximum of {max}")] + ScopeNestingLimitExceeded { depth: usize, max: usize }, + + #[error("duplicate case value '{value}' in switch statement")] + DuplicateSwitchCase { + value: String, + location: SourceLocation, + }, + + // ========================================================================= + // Reference Safety Errors (safe-by-constraint memory model) + // ========================================================================= + #[error( + "cannot return reference to local variable '{name}' - local variables are deallocated when the function returns" + )] + ReturnReferenceToLocal { + name: String, + location: SourceLocation, + }, + + #[error( + "cannot return slice of local array '{name}' - local arrays are deallocated when the function returns" + )] + ReturnSliceOfLocal { + name: String, + location: SourceLocation, + }, + + #[error( + "cannot store reference to local '{name}' in outer scope - would create dangling reference" + )] + ReferenceEscapesScope { + name: String, + location: SourceLocation, + }, +} + +/// Boxed payload for [`SemanticError::OverlappingAlias`]. +/// +/// Kept behind a `Box` so the large set of fields does not inflate +/// `SemanticError` (and therefore every `SemanticResult`) past clippy's +/// `result_large_err` size threshold. +#[derive(Debug, Clone)] +pub struct OverlappingAliasError { + pub new_alias: String, + pub existing_alias: String, + pub source_var: String, + pub overlap_range: String, + pub location: SourceLocation, +} + +impl SemanticError { + /// Get the source location of the error, if available. + pub fn location(&self) -> Option<&SourceLocation> { + match self { + Self::UndefinedSymbol { location, .. } => Some(location), + Self::DuplicateSymbol { location, .. } => Some(location), + Self::TypeMismatch { location, .. } => Some(location), + Self::CannotInferType { location, .. } => Some(location), + Self::EmptyArrayNeedsType { location } => Some(location), + Self::EmptySetNeedsType { location } => Some(location), + Self::InvalidBitWidth { location, .. } => Some(location), + Self::GateArityMismatch { location, .. } => Some(location), + Self::AmbiguousGateTarget { location, .. } => Some(location), + Self::InvalidGateSyntax { location, .. } => Some(location), + Self::InvalidQubitRef { location } => Some(location), + Self::AllocatorNotFound { location, .. } => Some(location), + Self::ComptimeError { location, .. } => Some(location), + Self::FunctionNotFound { location, .. } => Some(location), + Self::NotCallable { location } => Some(location), + Self::ArgumentCountMismatch { location, .. } => Some(location), + Self::QubitNotPrepared { location, .. } => Some(location), + Self::QubitAlreadyPrepared { location, .. } => Some(location), + Self::QubitIndexOutOfBounds { location, .. } => Some(location), + Self::ArrayIndexOutOfBounds { location, .. } => Some(location), + Self::ChildRequiresMutableParent { location, .. } => Some(location), + Self::ImmutableAssignment { location, .. } => Some(location), + Self::UnboundedLoop { location } => Some(location), + Self::LoopBoundTooLarge { location, .. } => Some(location), + Self::RecursionDetected { location, .. } => Some(location), + Self::InvalidMeasurementType { location, .. } => Some(location), + Self::MeasurementMissingArgs { location } => Some(location), + Self::DeprecatedMeasurementSyntax { location } => Some(location), + Self::DeprecatedSyntax { location, .. } => Some(location), + Self::MeasurementSizeMismatch { location, .. } => Some(location), + Self::MeasurementScalarExpected { location } => Some(location), + Self::MeasurementArrayExpected { location } => Some(location), + Self::MeasurementPackCapacity { location, .. } => Some(location), + Self::MeasurementPackUnknownSize { location, .. } => Some(location), + Self::DuplicateQubitInTick { location, .. } => Some(location), + Self::NestedTick { location } => Some(location), + Self::DuplicateQubitInMeasurement { location, .. } => Some(location), + Self::BreakContinueOutsideLoop { location, .. } => Some(location), + Self::InlineForRangeNotComptime { location, .. } => Some(location), + Self::BreakInInlineFor { location } => Some(location), + Self::ContinueInInlineFor { location } => Some(location), + Self::OverlappingAlias(e) => Some(&e.location), + Self::AliasSourceNotSlice { location, .. } => Some(location), + Self::AliasRangeNotComptime { location, .. } => Some(location), + Self::MissingReturn { location, .. } => Some(location), + Self::ReturnWithoutValue { location, .. } => Some(location), + Self::ModuleError { location, .. } => Some(location), + Self::CatchOnNonErrorType { location, .. } => Some(location), + Self::UndefinedType { location, .. } => Some(location), + Self::UnresolvedType { location, .. } => Some(location), + Self::SymbolTableLimitExceeded { .. } => None, + Self::ScopeNestingLimitExceeded { .. } => None, + Self::DuplicateSwitchCase { location, .. } => Some(location), + Self::ReturnReferenceToLocal { location, .. } => Some(location), + Self::ReturnSliceOfLocal { location, .. } => Some(location), + Self::ReferenceEscapesScope { location, .. } => Some(location), + Self::Other { .. } => None, + } + } +} + +pub type SemanticResult = Result; + +/// Multiple semantic errors collected during analysis. +/// +/// This type is returned by `analyze_collecting_errors` to provide all +/// errors at once, allowing developers to fix multiple issues in one pass. +#[derive(Debug, Clone)] +pub struct SemanticErrors { + errors: Vec, +} + +impl SemanticErrors { + /// Create a new collection of semantic errors. + pub fn new(errors: Vec) -> Self { + Self { errors } + } + + /// Get the number of errors. + pub fn len(&self) -> usize { + self.errors.len() + } + + /// Check if there are no errors. + pub fn is_empty(&self) -> bool { + self.errors.is_empty() + } + + /// Get an iterator over the errors. + pub fn iter(&self) -> impl Iterator { + self.errors.iter() + } + + /// Get the first error, if any. + pub fn first(&self) -> Option<&SemanticError> { + self.errors.first() + } + + /// Convert to a Vec of errors. + pub fn into_vec(self) -> Vec { + self.errors + } +} + +impl std::fmt::Display for SemanticErrors { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Found {} error(s):", self.errors.len())?; + for (i, err) in self.errors.iter().enumerate() { + writeln!(f, " {}. {}", i + 1, err)?; + } + Ok(()) + } +} + +impl std::error::Error for SemanticErrors {} + +impl IntoIterator for SemanticErrors { + type Item = SemanticError; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.errors.into_iter() + } +} + +impl<'a> IntoIterator for &'a SemanticErrors { + type Item = &'a SemanticError; + type IntoIter = std::slice::Iter<'a, SemanticError>; + + fn into_iter(self) -> Self::IntoIter { + self.errors.iter() + } +} + +// ============================================================================= +// Input Size Limits +// ============================================================================= + +/// Maximum number of symbols allowed in the symbol table. +/// This prevents memory exhaustion from programs with excessive declarations. +pub const MAX_SYMBOL_COUNT: usize = 100_000; + +/// Maximum scope nesting depth. +/// This prevents stack overflow from deeply nested scopes. +pub const MAX_SCOPE_DEPTH: usize = 256; + +// ============================================================================= +// Resolved Types +// ============================================================================= + +/// A validated bit width for integer types. +/// +/// Guarantees the value is in the valid range 1-128 (matching Rust's max integer size). +/// Once constructed, a BitWidth is always valid - invalid values are unrepresentable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BitWidth(u16); + +impl BitWidth { + /// Minimum valid bit width. + pub const MIN: u16 = 1; + /// Maximum valid bit width (matches Rust's i128/u128). + pub const MAX: u16 = 128; + + /// Create a new BitWidth if the value is valid (1-128). + /// Returns None for invalid values like 0 or 129+. + pub const fn new(bits: u16) -> Option { + if bits >= Self::MIN && bits <= Self::MAX { + Some(Self(bits)) + } else { + None + } + } + + /// Create a BitWidth without validation. + /// # Safety + /// The caller must ensure bits is in range 1-128. + pub const unsafe fn new_unchecked(bits: u16) -> Self { + Self(bits) + } + + /// Common bit width constants. + pub const BITS_1: Self = Self(1); + pub const BITS_8: Self = Self(8); + pub const BITS_16: Self = Self(16); + pub const BITS_32: Self = Self(32); + pub const BITS_64: Self = Self(64); + pub const BITS_128: Self = Self(128); + + /// Get the bit width value. + pub const fn get(self) -> u16 { + self.0 + } + + /// Create a BitWidth, panicking if invalid. + /// Use only for compile-time known values. + #[track_caller] + pub const fn must(bits: u16) -> Self { + match Self::new(bits) { + Some(bw) => bw, + None => panic!("invalid bit width"), + } + } +} + +impl std::fmt::Display for BitWidth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for BitWidth { + type Error = &'static str; + + fn try_from(bits: u16) -> Result { + Self::new(bits).ok_or("bit width must be between 1 and 128") + } +} + +/// Resolved types after semantic analysis. +/// +/// Unlike `TypeExpr` which is a syntactic representation, `Type` is the +/// semantic representation with all information resolved. +#[derive(Debug, Clone, PartialEq)] +pub enum Type { + // Primitives + Bool, + // Arbitrary-width integers (like Zig: u1, u4, u7, u128, etc.) + UInt { + bits: BitWidth, + }, // Unsigned integer with N bits + IInt { + bits: BitWidth, + }, // Signed integer with N bits + Usize, // Platform-dependent unsigned size + Isize, // Platform-dependent signed size + // Floating point + F16, + F32, + F64, + F128, + A64, // Angle type (maps to PECOS Angle64) + + // Quantum types + Qubit, + Bit, + /// Allocator with known capacity + Allocator { + capacity: Option, + }, + + // Compound types + Array { + element: Box, + size: Option, + }, + Slice { + element: Box, + }, + Set { + element: Box, + }, + Pointer { + pointee: Box, + is_const: bool, + is_many: bool, + }, + Optional { + inner: Box, + }, + ErrorUnion { + error: Box, + payload: Box, + }, + /// Collected errors: []E!T - array of errors E with value T (both, not either/or) + /// Used for QEC-style error collection + CollectedErrors { + error: Box, + payload: Box, + }, + /// Tuple type: (T1, T2, ...) + Tuple { + elements: Vec, + }, + + // Function type + Function { + params: Vec, + return_type: Box, + }, + + // Named/user-defined types + Struct { + name: String, + fields: Vec<(String, Type)>, + }, + Enum { + name: String, + variants: Vec, + }, + Union { + name: String, + /// Fields of the union: (name, optional payload type) + fields: Vec<(String, Option)>, + /// Is this union tagged (has an auto-generated or external tag)? + is_tagged: bool, + }, + /// Classical error set - crashes if unhandled + /// Each variant is (name, optional_associated_data_type) + ErrorSet { + name: String, + errors: Vec<(String, Option>)>, + }, + /// Quantum fault set - collected in try blocks + /// Each variant is (name, optional_associated_data_type) + FaultSet { + name: String, + faults: Vec<(String, Option>)>, + }, + /// The `anyerror` type - represents any error type + AnyError, + /// The `anyfault` type - represents any fault type + AnyFault, + + // Special types + Unit, // Unit type - has exactly one value + Type, // The metatype (type of types) + Comptime(Box), // Comptime-known value of this type + Never, // Bottom type (for functions that don't return) + + /// Imported module type + Module { + /// Absolute path to the module file + path: String, + /// Exported symbols: name -> (kind, type) + exports: std::collections::BTreeMap, + }, + + // Unknown (for type inference) + Unknown, +} + +/// Kind of exported symbol from a module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModuleExportKind { + Function, + Const, + Type, + ErrorSet, + FaultSet, +} + +impl Type { + /// Check if this type is numeric. + pub fn is_numeric(&self) -> bool { + matches!( + self, + Type::UInt { .. } + | Type::IInt { .. } + | Type::Usize + | Type::Isize + | Type::F16 + | Type::F32 + | Type::F64 + | Type::F128 + | Type::A64 + ) + } + + /// Check if this type is an integer. + pub fn is_integer(&self) -> bool { + matches!( + self, + Type::UInt { .. } | Type::IInt { .. } | Type::Usize | Type::Isize + ) + } + + /// Check if this type is a floating point. + pub fn is_float(&self) -> bool { + matches!(self, Type::F16 | Type::F32 | Type::F64 | Type::F128) + } + + /// Check if this type is a quantum type. + pub fn is_quantum(&self) -> bool { + matches!(self, Type::Qubit | Type::Bit | Type::Allocator { .. }) + } + + /// Get the display name for error messages. + pub fn display_name(&self) -> String { + match self { + Type::Bool => "bool".to_string(), + Type::UInt { bits } => format!("u{bits}"), + Type::IInt { bits } => format!("i{bits}"), + Type::Usize => "usize".to_string(), + Type::Isize => "isize".to_string(), + Type::F16 => "f16".to_string(), + Type::F32 => "f32".to_string(), + Type::F64 => "f64".to_string(), + Type::F128 => "f128".to_string(), + Type::A64 => "a64".to_string(), + Type::Qubit => "qubit".to_string(), + Type::Bit => "bit".to_string(), + Type::Allocator { capacity: Some(n) } => format!("qalloc({n})"), + Type::Allocator { capacity: None } => "qalloc".to_string(), + Type::Array { + element, + size: Some(n), + } => format!("[{n}]{}", element.display_name()), + Type::Array { + element, + size: None, + } => format!("[_]{}", element.display_name()), + Type::Slice { element } => format!("[]{}", element.display_name()), + Type::Pointer { + pointee, is_const, .. + } => { + if *is_const { + format!("*const {}", pointee.display_name()) + } else { + format!("*{}", pointee.display_name()) + } + } + Type::Optional { inner } => format!("?{}", inner.display_name()), + Type::ErrorUnion { error, payload } => { + // Syntax is E!T where E is error type and T is payload type + format!("{}!{}", error.display_name(), payload.display_name()) + } + Type::CollectedErrors { error, payload } => { + // Syntax is []E!T where E is error type and T is payload type + format!("[]{}!{}", error.display_name(), payload.display_name()) + } + Type::Tuple { elements } => { + let elem_strs: Vec<_> = elements.iter().map(|e| e.display_name()).collect(); + format!("({})", elem_strs.join(", ")) + } + Type::Function { + params, + return_type, + } => { + let params_str: Vec<_> = params.iter().map(|p| p.display_name()).collect(); + format!( + "fn({}) {}", + params_str.join(", "), + return_type.display_name() + ) + } + Type::Struct { name, .. } => name.clone(), + Type::Enum { name, .. } => name.clone(), + Type::Union { name, .. } => name.clone(), + Type::ErrorSet { name, .. } => format!("error.{}", name), + Type::FaultSet { name, .. } => format!("fault.{}", name), + Type::AnyError => "anyerror".to_string(), + Type::AnyFault => "anyfault".to_string(), + Type::Unit => "unit".to_string(), + Type::Type => "type".to_string(), + Type::Comptime(inner) => format!("comptime {}", inner.display_name()), + Type::Never => "noreturn".to_string(), + Type::Unknown => "unknown".to_string(), + Type::Set { element } => format!("Set({})", element.display_name()), + Type::Module { path, .. } => format!("module({})", path), + } + } + + /// Check if this type contains `Unknown` anywhere in its structure. + /// + /// Returns `true` if this type is `Unknown` or contains `Unknown` in any + /// nested position (e.g., `Array { element: Unknown, .. }`). + pub fn contains_unknown(&self) -> bool { + match self { + Type::Unknown => true, + + // Primitives never contain Unknown + Type::Bool + | Type::UInt { .. } + | Type::IInt { .. } + | Type::Usize + | Type::Isize + | Type::F16 + | Type::F32 + | Type::F64 + | Type::F128 + | Type::A64 + | Type::Qubit + | Type::Bit + | Type::Unit + | Type::Type + | Type::Never + | Type::AnyError + | Type::AnyFault => false, + + // Check allocator (no nested types) + Type::Allocator { .. } => false, + + // Container types - check nested types + Type::Array { element, .. } => element.contains_unknown(), + Type::Slice { element } => element.contains_unknown(), + Type::Set { element } => element.contains_unknown(), + Type::Pointer { pointee, .. } => pointee.contains_unknown(), + Type::Optional { inner } => inner.contains_unknown(), + Type::Comptime(inner) => inner.contains_unknown(), + + // Compound types with two nested types + Type::ErrorUnion { error, payload } => { + error.contains_unknown() || payload.contains_unknown() + } + Type::CollectedErrors { error, payload } => { + error.contains_unknown() || payload.contains_unknown() + } + + // Collections of types + Type::Tuple { elements } => elements.iter().any(|e| e.contains_unknown()), + Type::Function { + params, + return_type, + } => params.iter().any(|p| p.contains_unknown()) || return_type.contains_unknown(), + + // Named types with fields + Type::Struct { fields, .. } => fields.iter().any(|(_, ty)| ty.contains_unknown()), + Type::Union { fields, .. } => fields + .iter() + .any(|(_, opt_ty)| opt_ty.as_ref().is_some_and(|ty| ty.contains_unknown())), + + // Named types without nested types to check + Type::Enum { .. } | Type::ErrorSet { .. } | Type::FaultSet { .. } => false, + + // Module exports + Type::Module { exports, .. } => exports.values().any(|(_, ty)| ty.contains_unknown()), + } + } + + /// Try to resolve this type, returning `Some(ResolvedType)` if it contains + /// no `Unknown` anywhere in its structure, or `None` otherwise. + pub fn resolve(&self) -> Option { + if self.contains_unknown() { + None + } else { + Some(ResolvedType(self.clone())) + } + } + + /// Check if this type is fully resolved (contains no Unknown). + pub fn is_resolved(&self) -> bool { + !self.contains_unknown() + } +} + +/// A type that is guaranteed to contain no `Unknown` anywhere in its structure. +/// +/// This wrapper provides type-level safety at API boundaries where we need to +/// guarantee that type inference has completed. Use `Type::resolve()` to create +/// a `ResolvedType` from a `Type`. +/// +/// # Example +/// +/// ```rust +/// use zlup::semantic::Type; +/// +/// // ResolvedType guarantees no Unknown variants +/// let ty = Type::Bool.resolve().expect("Bool is resolved"); +/// assert!(matches!(ty.as_type(), Type::Bool)); +/// +/// // Unknown types cannot become ResolvedType +/// let unknown = Type::Unknown.resolve(); +/// assert!(unknown.is_none()); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedType(Type); + +impl ResolvedType { + /// Get the underlying `Type`. + /// + /// The returned type is guaranteed to not contain `Unknown`. + pub fn as_type(&self) -> &Type { + &self.0 + } + + /// Unwrap into the underlying `Type`. + /// + /// The returned type is guaranteed to not contain `Unknown`. + pub fn into_type(self) -> Type { + self.0 + } + + /// Get the display name for error messages. + pub fn display_name(&self) -> String { + self.0.display_name() + } + + /// Check if this type is numeric. + pub fn is_numeric(&self) -> bool { + self.0.is_numeric() + } + + /// Check if this type is an integer. + pub fn is_integer(&self) -> bool { + self.0.is_integer() + } + + /// Check if this type is a floating point. + pub fn is_float(&self) -> bool { + self.0.is_float() + } + + /// Check if this type is a quantum type. + pub fn is_quantum(&self) -> bool { + self.0.is_quantum() + } +} + +impl std::fmt::Display for ResolvedType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.display_name()) + } +} + +// ============================================================================= +// Symbol Table +// ============================================================================= + +/// Symbol kinds. +#[derive(Debug, Clone)] +pub enum SymbolKind { + /// Local or global variable + Variable { + ty: Type, + is_const: bool, + is_comptime: bool, + }, + /// Function + Function { + params: Vec<(String, Type)>, + return_type: Type, + is_pub: bool, + /// Which parameter indices are comptime (for generic functions) + comptime_param_indices: Vec, + /// Original function declaration (for instantiation of generics) + original_decl: Option>, + }, + /// Type definition (struct, enum) + TypeDef { ty: Type }, + /// Qubit allocator + Allocator { capacity: Option }, + /// Parameter + Parameter { ty: Type, is_comptime: bool }, +} + +/// A symbol in the symbol table. +#[derive(Debug, Clone)] +pub struct Symbol { + pub name: String, + pub kind: SymbolKind, + pub location: Option, +} + +/// A scope containing symbols. +#[derive(Debug)] +pub struct Scope { + /// Symbols defined in this scope + symbols: BTreeMap, + /// Parent scope index (None for global scope) + parent: Option, + /// Scope kind for context + kind: ScopeKind, +} + +/// Scope kinds for different contexts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScopeKind { + Global, + Function, + Block, + Loop, + Struct, +} + +/// Symbol table with nested scopes. +#[derive(Debug)] +pub struct SymbolTable { + scopes: Vec, + current_scope: usize, +} + +impl SymbolTable { + /// Create a new symbol table with a global scope. + pub fn new() -> Self { + let global_scope = Scope { + symbols: BTreeMap::new(), + parent: None, + kind: ScopeKind::Global, + }; + Self { + scopes: vec![global_scope], + current_scope: 0, + } + } + + /// Push a new scope. + pub fn push_scope(&mut self, kind: ScopeKind) -> SemanticResult<()> { + // Check scope nesting limit + let depth = self.scope_depth(); + if depth >= MAX_SCOPE_DEPTH { + return Err(SemanticError::ScopeNestingLimitExceeded { + depth: depth + 1, + max: MAX_SCOPE_DEPTH, + }); + } + + let new_scope = Scope { + symbols: BTreeMap::new(), + parent: Some(self.current_scope), + kind, + }; + self.scopes.push(new_scope); + self.current_scope = self.scopes.len() - 1; + Ok(()) + } + + /// Calculate current scope nesting depth. + fn scope_depth(&self) -> usize { + let mut depth = 0; + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + depth += 1; + scope_idx = self.scopes[idx].parent; + } + depth + } + + /// Pop the current scope. + pub fn pop_scope(&mut self) { + if let Some(parent) = self.scopes[self.current_scope].parent { + self.current_scope = parent; + } + } + + /// Define a symbol in the current scope. + pub fn define(&mut self, symbol: Symbol) -> SemanticResult<()> { + // Check symbol table size limit + let total_symbols: usize = self.scopes.iter().map(|s| s.symbols.len()).sum(); + if total_symbols >= MAX_SYMBOL_COUNT { + return Err(SemanticError::SymbolTableLimitExceeded { + count: total_symbols + 1, + max: MAX_SYMBOL_COUNT, + }); + } + + let scope = &mut self.scopes[self.current_scope]; + if scope.symbols.contains_key(&symbol.name) { + return Err(SemanticError::DuplicateSymbol { + name: symbol.name.clone(), + location: symbol.location.clone().unwrap_or_default(), + }); + } + scope.symbols.insert(symbol.name.clone(), symbol); + Ok(()) + } + + /// Look up a symbol, searching parent scopes. + pub fn lookup(&self, name: &str) -> Option<&Symbol> { + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + let scope = &self.scopes[idx]; + if let Some(symbol) = scope.symbols.get(name) { + return Some(symbol); + } + scope_idx = scope.parent; + } + None + } + + /// Look up a symbol only in the current scope. + pub fn lookup_current(&self, name: &str) -> Option<&Symbol> { + self.scopes[self.current_scope].symbols.get(name) + } + + /// Get the current scope kind. + pub fn current_scope_kind(&self) -> ScopeKind { + self.scopes[self.current_scope].kind + } + + /// Check if we're inside a loop. + pub fn in_loop(&self) -> bool { + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + if self.scopes[idx].kind == ScopeKind::Loop { + return true; + } + scope_idx = self.scopes[idx].parent; + } + false + } + + /// Find an error set containing the given variant name. + /// Returns the Type::ErrorSet if found. + pub fn find_error_set_by_variant(&self, variant_name: &str) -> Option { + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + let scope = &self.scopes[idx]; + for symbol in scope.symbols.values() { + if let SymbolKind::TypeDef { ty } = &symbol.kind + && let Type::ErrorSet { name, errors } = ty + && errors.iter().any(|(n, _)| n == variant_name) + { + return Some(Type::ErrorSet { + name: name.clone(), + errors: errors.clone(), + }); + } + } + scope_idx = scope.parent; + } + None + } + + /// Find a fault set containing the given variant name. + /// Returns the Type::FaultSet if found. + pub fn find_fault_set_by_variant(&self, variant_name: &str) -> Option { + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + let scope = &self.scopes[idx]; + for symbol in scope.symbols.values() { + if let SymbolKind::TypeDef { ty } = &symbol.kind + && let Type::FaultSet { name, faults } = ty + && faults.iter().any(|(n, _)| n == variant_name) + { + return Some(Type::FaultSet { + name: name.clone(), + faults: faults.clone(), + }); + } + } + scope_idx = scope.parent; + } + None + } +} + +impl Default for SymbolTable { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Qubit State Tracking +// ============================================================================= + +/// Qubit slot state - exactly two states as per Zluppy design. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QubitState { + /// Initial state, or after measurement. Cannot apply gates. + Unprepared, + /// Ready for gate operations. Result of prepare(). + Prepared, +} + +impl QubitState { + /// Check if gates can be applied to this qubit. + pub fn can_apply_gate(&self) -> bool { + matches!(self, QubitState::Prepared) + } +} + +/// Information about an allocator for tracking. +#[derive(Debug, Clone)] +pub struct AllocatorInfo { + /// Name of the allocator variable. + pub name: String, + /// Known capacity (if comptime-known). + pub capacity: Option, + /// Parent allocator (for child allocators). + pub parent: Option, + /// State of each qubit slot. + pub slot_states: Vec, +} + +impl AllocatorInfo { + /// Create a new allocator with given capacity. + pub fn new(name: impl Into, capacity: usize) -> Self { + Self { + name: name.into(), + capacity: Some(capacity), + parent: None, + slot_states: vec![QubitState::Unprepared; capacity], + } + } + + /// Create a child allocator. + pub fn child(name: impl Into, parent: impl Into, capacity: usize) -> Self { + Self { + name: name.into(), + capacity: Some(capacity), + parent: Some(parent.into()), + slot_states: vec![QubitState::Unprepared; capacity], + } + } + + /// Create an allocator with unknown capacity. + pub fn unknown(name: impl Into) -> Self { + Self { + name: name.into(), + capacity: None, + parent: None, + slot_states: Vec::new(), + } + } + + /// Prepare a specific slot. + pub fn prepare_slot(&mut self, index: usize) -> Result<(), (usize, QubitState)> { + if let Some(state) = self.slot_states.get_mut(index) { + if *state == QubitState::Prepared { + return Err((index, *state)); + } + *state = QubitState::Prepared; + Ok(()) + } else if self.capacity.is_none() { + // Unknown capacity - assume valid + Ok(()) + } else { + Err((index, QubitState::Unprepared)) + } + } + + /// Prepare all slots. + pub fn prepare_all(&mut self) { + for state in &mut self.slot_states { + *state = QubitState::Prepared; + } + } + + /// Measure a specific slot (transitions to unprepared). + pub fn measure_slot(&mut self, index: usize) { + if let Some(state) = self.slot_states.get_mut(index) { + *state = QubitState::Unprepared; + } + } + + /// Get the state of a slot. + pub fn get_state(&self, index: usize) -> Option { + self.slot_states.get(index).copied() + } + + /// Check if an index is in bounds. + pub fn is_in_bounds(&self, index: usize) -> bool { + match self.capacity { + Some(cap) => index < cap, + None => true, // Unknown capacity - assume valid + } + } +} + +/// Tracks qubit states across the program. +#[derive(Debug, Default)] +pub struct QubitStateTracker { + /// Allocators by name. + allocators: BTreeMap, +} + +impl QubitStateTracker { + /// Create a new tracker. + pub fn new() -> Self { + Self::default() + } + + /// Register an allocator. + pub fn register_allocator(&mut self, info: AllocatorInfo) { + self.allocators.insert(info.name.clone(), info); + } + + /// Get an allocator by name. + pub fn get_allocator(&self, name: &str) -> Option<&AllocatorInfo> { + self.allocators.get(name) + } + + /// Get a mutable allocator by name. + pub fn get_allocator_mut(&mut self, name: &str) -> Option<&mut AllocatorInfo> { + self.allocators.get_mut(name) + } + + /// Check if a qubit slot is prepared for gate operations. + pub fn is_prepared(&self, allocator: &str, index: usize) -> Option { + self.allocators + .get(allocator) + .and_then(|a| a.get_state(index)) + .map(|s| s == QubitState::Prepared) + } + + /// Validate a qubit reference for gate operations. + pub fn validate_for_gate( + &self, + allocator: &str, + index: usize, + location: &SourceLocation, + ) -> SemanticResult<()> { + let alloc = + self.allocators + .get(allocator) + .ok_or_else(|| SemanticError::AllocatorNotFound { + name: allocator.to_string(), + location: location.clone(), + })?; + + // Check bounds + if !alloc.is_in_bounds(index) { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: allocator.to_string(), + index, + capacity: alloc.capacity.unwrap_or(0), + location: location.clone(), + }); + } + + // Check state (only if capacity is known) + if alloc.capacity.is_some() + && let Some(state) = alloc.get_state(index) + && !state.can_apply_gate() + { + return Err(SemanticError::QubitNotPrepared { + allocator: allocator.to_string(), + index, + location: location.clone(), + }); + } + + Ok(()) + } +} + +// ============================================================================= +// NASA Power of 10 Checks +// ============================================================================= + +/// Maximum allowed loop bound (NASA Power of 10 Rule 2). +pub const MAX_LOOP_BOUND: usize = 1_000_000; + +/// Tracks function calls to detect recursion. +#[derive(Debug, Default)] +pub struct RecursionTracker { + /// Currently active function call stack. + call_stack: BTreeSet, +} + +impl RecursionTracker { + pub fn new() -> Self { + Self::default() + } + + /// Enter a function. Returns error if already in the call stack. + pub fn enter_function(&mut self, name: &str, location: &SourceLocation) -> SemanticResult<()> { + if self.call_stack.contains(name) { + return Err(SemanticError::RecursionDetected { + name: name.to_string(), + location: location.clone(), + }); + } + self.call_stack.insert(name.to_string()); + Ok(()) + } + + /// Exit a function. + pub fn exit_function(&mut self, name: &str) { + self.call_stack.remove(name); + } + + /// Check if a function is in the call stack. + pub fn is_in_call_stack(&self, name: &str) -> bool { + self.call_stack.contains(name) + } +} + +// ============================================================================= +// Semantic Analyzer +// ============================================================================= + +/// Semantic analyzer for Zluppy programs. +pub struct SemanticAnalyzer { + /// Symbol table + pub symbols: SymbolTable, + /// Qubit state tracker + pub qubit_states: QubitStateTracker, + /// Recursion tracker (NASA Power of 10) + pub recursion_tracker: RecursionTracker, + /// Current function return type (for return statement checking) + current_return_type: Option, + /// Current function name (for call tracking) + current_function: Option, + /// Whether to enforce strict qubit state checking + strict_mode: bool, + /// Errors collected during analysis + errors: Vec, + /// Comptime evaluator for compile-time expression evaluation + comptime: ComptimeEvaluator, + /// Storage for comptime-evaluated values by expression location + comptime_values: BTreeMap, + /// Module loader for handling @import + module_loader: ModuleLoader, + /// Current file path (for resolving relative imports) + current_file: Option, + /// Loop nesting depth (for validating break/continue) + loop_depth: usize, + /// Inline for nesting depth (to disallow break/continue in inline for) + inline_for_depth: usize, + /// Tick nesting depth (to disallow nested ticks) + tick_depth: usize, + /// Call graph for mutual recursion detection (strict mode) + /// Maps function name -> set of functions it calls + call_graph: BTreeMap>, + /// Set of user-defined function names (for call graph filtering) + user_functions: BTreeSet, + /// Cache of generic function instantiations. + /// Key: (original function name, serialized comptime arg values) + /// Value: mangled name of the specialized function + generic_instantiations: BTreeMap<(String, String), String>, + /// Storage for specialized function declarations generated from generics + specialized_functions: Vec, + /// Alias tracking for overlap detection + /// Key: alias name, Value: AliasInfo + aliases: BTreeMap, + /// Gate registry for custom gate declarations + gate_registry: BTreeMap, +} + +/// Where a registered gate came from. Determines which redeclarations are +/// allowed: built-ins may be redeclared only with their exact signature, while a +/// user gate (declared or defined) may not be redeclared at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateOrigin { + /// A built-in gate provided by the language/backend. + Builtin, + /// `declare gate name(...)(...);` -- an opaque target/backend gate. + TargetDeclared, + /// `gate name(...)(...) { ... }` -- a composite gate defined inline. + CompositeDefined, +} + +/// Signature of a registered gate (built-in or custom). +#[derive(Debug, Clone)] +pub struct GateSignature { + pub name: String, + pub num_params: usize, + pub num_qubits: usize, + pub origin: GateOrigin, +} + +/// Information about an alias for overlap detection. +#[derive(Debug, Clone)] +pub struct AliasInfo { + /// Name of the alias + pub name: String, + /// Name of the source variable + pub source: String, + /// Static range if known (start..end) + pub range: Option<(i64, i64)>, + /// Source location for error reporting + pub location: SourceLocation, +} + +impl SemanticAnalyzer { + /// Create a new semantic analyzer with strict mode enabled (the default). + /// + /// Strict mode enforces safety guarantees required for quantum programs: + /// - Qubit state checking (gates on unprepared qubits are errors) + /// - Loop bounds checked against MAX_LOOP_BOUND + /// - Recursion is prohibited (use FFI with Rust if needed) + /// - Explicit return statements required + /// + /// This is the recommended mode for quantum programs. + pub fn new() -> Self { + let mut analyzer = Self { + symbols: SymbolTable::new(), + qubit_states: QubitStateTracker::new(), + recursion_tracker: RecursionTracker::new(), + current_return_type: None, + current_function: None, + strict_mode: true, // Strict by default for quantum safety + errors: Vec::new(), + comptime: ComptimeEvaluator::new(), + comptime_values: BTreeMap::new(), + module_loader: ModuleLoader::new(), + current_file: None, + loop_depth: 0, + inline_for_depth: 0, + tick_depth: 0, + call_graph: BTreeMap::new(), + user_functions: BTreeSet::new(), + generic_instantiations: BTreeMap::new(), + specialized_functions: Vec::new(), + aliases: BTreeMap::new(), + gate_registry: BTreeMap::new(), + }; + analyzer.define_builtins(); + analyzer.populate_builtin_gates(); + analyzer + } + + /// Set the current file path for resolving relative imports. + pub fn set_current_file(&mut self, path: impl Into) { + self.current_file = Some(path.into()); + } + + /// Create a new semantic analyzer with permissive mode (strict mode disabled). + /// + /// Permissive mode relaxes some safety checks: + /// - Qubit state checking is not enforced + /// - Loop bounds are not checked + /// + /// Note: Recursion is always prohibited (use FFI with Rust if needed). + /// + /// Use this mode only when interfacing with external code or for testing. + /// For production quantum programs, use `new()` (strict mode). + pub fn new_permissive() -> Self { + let mut analyzer = Self::new(); + analyzer.strict_mode = false; + analyzer + } + + /// Enable or disable strict mode. + pub fn set_strict_mode(&mut self, strict: bool) { + self.strict_mode = strict; + } + + /// Define built-in functions and types. + fn define_builtins(&mut self) { + // Built-in quantum gates are recognized by name during call analysis + // Built-in types are handled in resolve_type + + // Define qalloc as a built-in function that returns an allocator + let _ = self.symbols.define(Symbol { + name: "qalloc".to_string(), + kind: SymbolKind::Function { + params: vec![( + "capacity".to_string(), + Type::UInt { + bits: BitWidth::BITS_32, + }, + )], + return_type: Type::Allocator { capacity: None }, + is_pub: true, + comptime_param_indices: vec![], + original_decl: None, + }, + location: None, + }); + + // Define measure as a built-in + let _ = self.symbols.define(Symbol { + name: "measure".to_string(), + kind: SymbolKind::Function { + params: vec![("target".to_string(), Type::Qubit)], + return_type: Type::Bit, + is_pub: true, + comptime_param_indices: vec![], + original_decl: None, + }, + location: None, + }); + } + + /// Populate the gate registry with all built-in gates. + fn populate_builtin_gates(&mut self) { + let builtin_gates: &[(&str, usize, usize)] = &[ + // (name, num_params, num_qubits) + ("x", 0, 1), + ("y", 0, 1), + ("z", 0, 1), + ("h", 0, 1), + ("t", 0, 1), + ("tdg", 0, 1), + ("sx", 0, 1), + ("sy", 0, 1), + ("sz", 0, 1), + ("sxdg", 0, 1), + ("sydg", 0, 1), + ("szdg", 0, 1), + ("rx", 1, 1), + ("ry", 1, 1), + ("rz", 1, 1), + ("cx", 0, 2), + ("cy", 0, 2), + ("cz", 0, 2), + ("ch", 0, 2), + ("sxx", 0, 2), + ("syy", 0, 2), + ("szz", 0, 2), + ("sxxdg", 0, 2), + ("syydg", 0, 2), + ("szzdg", 0, 2), + ("rzz", 1, 2), + ("swap", 0, 2), + ("iswap", 0, 2), + ("ccx", 0, 3), + ("f", 0, 1), + ("fdg", 0, 1), + ("f4", 0, 1), + ("f4dg", 0, 1), + ("pz", 0, 1), + ]; + + for &(name, num_params, num_qubits) in builtin_gates { + self.gate_registry.insert( + name.to_string(), + GateSignature { + name: name.to_string(), + num_params, + num_qubits, + origin: GateOrigin::Builtin, + }, + ); + } + } + + /// Analyze a program. + pub fn analyze(&mut self, program: &Program) -> SemanticResult<()> { + log::debug!( + "Analyzing program with {} declarations (strict={})", + program.declarations.len(), + self.strict_mode + ); + + // First pass: collect all top-level declarations + log::trace!("Pass 1: collecting top-level declarations"); + for decl in &program.declarations { + self.collect_top_level(decl)?; + } + + // Second pass: analyze bodies + log::trace!("Pass 2: analyzing declaration bodies"); + for decl in &program.declarations { + self.analyze_top_level(decl)?; + } + + // Check for mutual recursion in strict mode + if self.strict_mode { + log::trace!("Pass 3: checking call graph for cycles"); + self.check_call_graph_cycles()?; + } + + // Validation pass: ensure no unresolved types remain in symbol table + // This is a safety net to catch any Unknown types that slip through + log::trace!("Pass 4: validating resolved types"); + self.validate_types_resolved(); + + if self.errors.is_empty() { + log::debug!("Semantic analysis completed successfully"); + Ok(()) + } else { + log::debug!("Semantic analysis found {} error(s)", self.errors.len()); + // Return the first error for backward compatibility + Err(self.errors.remove(0)) + } + } + + /// Analyze a program and return all collected errors. + /// + /// Unlike `analyze()` which returns the first error, this method + /// returns all errors found during analysis, allowing developers + /// to fix multiple issues in one pass. + /// + /// # Example + /// + /// ```rust + /// use zlup::semantic::SemanticAnalyzer; + /// + /// // Program with multiple errors + /// let source = "fn main() -> unit { x := undefined; y := also_undefined; return unit; }"; + /// let program = zlup::parse(source).expect("parse failed"); + /// let mut analyzer = SemanticAnalyzer::new(); + /// match analyzer.analyze_collecting_errors(&program) { + /// Ok(()) => println!("No errors"), + /// Err(errors) => { + /// // All errors collected, not just the first one + /// assert!(errors.len() >= 2); + /// } + /// } + /// ``` + pub fn analyze_collecting_errors(&mut self, program: &Program) -> Result<(), SemanticErrors> { + // First pass: collect all top-level declarations + for decl in &program.declarations { + if let Err(e) = self.collect_top_level(decl) { + self.errors.push(e); + } + } + + // Second pass: analyze bodies + for decl in &program.declarations { + if let Err(e) = self.analyze_top_level(decl) { + self.errors.push(e); + } + } + + // Validation pass: ensure no unresolved types remain in symbol table + self.validate_types_resolved(); + + if self.errors.is_empty() { + Ok(()) + } else { + Err(SemanticErrors::new(std::mem::take(&mut self.errors))) + } + } + + /// Get the number of errors collected so far. + pub fn error_count(&self) -> usize { + self.errors.len() + } + + /// Get all collected errors without consuming them. + pub fn errors(&self) -> &[SemanticError] { + &self.errors + } + + /// Take all collected errors, leaving the error list empty. + pub fn take_errors(&mut self) -> Vec { + std::mem::take(&mut self.errors) + } + + /// Check for cycles in the call graph (mutual recursion detection). + /// Uses DFS to detect back edges in the call graph. + fn check_call_graph_cycles(&self) -> SemanticResult<()> { + let mut visited = BTreeSet::new(); + let mut rec_stack = BTreeSet::new(); + + for func in &self.user_functions { + if !visited.contains(func) + && let Some(cycle_func) = self.dfs_detect_cycle(func, &mut visited, &mut rec_stack) + { + return Err(SemanticError::RecursionDetected { + name: cycle_func, + location: SourceLocation::default(), + }); + } + } + Ok(()) + } + + /// DFS helper for cycle detection. Returns the name of a function in a cycle if found. + fn dfs_detect_cycle( + &self, + func: &str, + visited: &mut BTreeSet, + rec_stack: &mut BTreeSet, + ) -> Option { + visited.insert(func.to_string()); + rec_stack.insert(func.to_string()); + + if let Some(callees) = self.call_graph.get(func) { + for callee in callees { + // If callee is in the current recursion stack, we found a cycle + if rec_stack.contains(callee) { + return Some(callee.clone()); + } + // If not visited, recurse + if !visited.contains(callee) + && let Some(cycle) = self.dfs_detect_cycle(callee, visited, rec_stack) + { + return Some(cycle); + } + } + } + + rec_stack.remove(func); + None + } + + /// Validate that no Unknown types remain in the symbol table. + /// + /// This is a safety net that runs after semantic analysis to ensure + /// all types are fully resolved before code generation. + fn validate_types_resolved(&mut self) { + for scope in &self.symbols.scopes { + for symbol in scope.symbols.values() { + let (ty, context) = match &symbol.kind { + SymbolKind::Variable { ty, .. } => (ty, "variable"), + SymbolKind::Function { return_type, .. } => { + (return_type, "function return type") + } + SymbolKind::TypeDef { ty } => (ty, "type definition"), + SymbolKind::Parameter { ty, .. } => (ty, "parameter"), + SymbolKind::Allocator { .. } => continue, + }; + + // Skip Module types - they can have Unknown in exports due to + // incomplete type extraction from imported modules + if matches!(ty, Type::Module { .. }) { + continue; + } + + if ty.contains_unknown() { + self.errors.push(SemanticError::UnresolvedType { + ty: ty.display_name(), + context: format!("{} '{}'", context, symbol.name), + location: symbol.location.clone().unwrap_or_default(), + }); + } + + // Also check function parameter types + if let SymbolKind::Function { params, .. } = &symbol.kind { + for (param_name, param_ty) in params { + if param_ty.contains_unknown() { + self.errors.push(SemanticError::UnresolvedType { + ty: param_ty.display_name(), + context: format!( + "parameter '{}' of function '{}'", + param_name, symbol.name + ), + location: symbol.location.clone().unwrap_or_default(), + }); + } + } + } + } + } + } + + /// Register a user-declared gate (target declaration or composite definition). + /// + /// Rejects a duplicate user gate (the same name declared/defined twice). + /// `declare gate` introduces an opaque target/backend gate and `gate ... {}` + /// a composite definition; PECOS treats either as a complete declaration, so + /// a second one of the same name -- including declare-then-define -- is a + /// duplicate, not a forward declaration. + /// + /// A built-in gate may only be redeclared with its exact signature (a + /// harmless no-op); shadowing a built-in with a different arity/parameter + /// count is rejected, because built-in names are parsed with a fixed + /// parameterization and a mismatched redeclaration would be uncallable. + fn register_user_gate( + &mut self, + name: &str, + num_params: usize, + num_qubits: usize, + origin: GateOrigin, + ) -> SemanticResult<()> { + if let Some(existing) = self.gate_registry.get(name) { + match existing.origin { + GateOrigin::Builtin => { + if existing.num_params != num_params || existing.num_qubits != num_qubits { + return Err(SemanticError::Other { + message: format!( + "cannot redeclare built-in gate '{name}' with a different \ + signature: built-in '{name}' takes {} parameter(s) and {} \ + qubit(s)", + existing.num_params, existing.num_qubits + ), + }); + } + // Exact-signature redeclaration of a built-in: no-op. + return Ok(()); + } + GateOrigin::TargetDeclared => { + return Err(SemanticError::Other { + message: format!( + "gate '{name}' is already declared as a target gate; \ + `declare gate` is an opaque backend gate, not a forward declaration" + ), + }); + } + GateOrigin::CompositeDefined => { + return Err(SemanticError::Other { + message: format!("gate '{name}' is already defined"), + }); + } + } + } + self.gate_registry.insert( + name.to_string(), + GateSignature { + name: name.to_string(), + num_params, + num_qubits, + origin, + }, + ); + Ok(()) + } + + /// Collect top-level declarations (forward declaration pass). + fn collect_top_level(&mut self, decl: &TopLevelDecl) -> SemanticResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) => { + let params: Vec<(String, Type)> = fn_decl + .params + .iter() + .map(|p| (p.name.clone(), self.resolve_type(&p.ty))) + .collect(); + let return_type = fn_decl + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + + // Collect indices of comptime parameters (for generic instantiation) + let comptime_param_indices: Vec = fn_decl + .params + .iter() + .enumerate() + .filter(|(_, p)| p.is_comptime) + .map(|(i, _)| i) + .collect(); + + // Store original declaration if function has comptime params (is generic) + let original_decl = if !comptime_param_indices.is_empty() { + Some(Box::new(fn_decl.clone())) + } else { + None + }; + + self.symbols.define(Symbol { + name: fn_decl.name.clone(), + kind: SymbolKind::Function { + params, + return_type, + is_pub: fn_decl.is_pub, + comptime_param_indices, + original_decl, + }, + location: fn_decl.location.clone(), + })?; + + // Track user-defined functions for call graph analysis + self.user_functions.insert(fn_decl.name.clone()); + } + TopLevelDecl::Struct(struct_decl) => { + let fields: Vec<(String, Type)> = struct_decl + .fields + .iter() + .map(|f| (f.name.clone(), self.resolve_type(&f.ty))) + .collect(); + + self.symbols.define(Symbol { + name: struct_decl.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Struct { + name: struct_decl.name.clone(), + fields, + }, + }, + location: struct_decl.location.clone(), + })?; + } + TopLevelDecl::Enum(enum_decl) => { + let variants: Vec = + enum_decl.variants.iter().map(|v| v.name.clone()).collect(); + + self.symbols.define(Symbol { + name: enum_decl.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Enum { + name: enum_decl.name.clone(), + variants, + }, + }, + location: enum_decl.location.clone(), + })?; + } + TopLevelDecl::Binding(binding) => { + // Binding declarations are collected but not fully analyzed yet + // For struct/enum type bindings, also register as TypeDef + let ty = if let Some(type_expr) = &binding.ty { + self.resolve_type(type_expr) + } else if let Some(value) = &binding.value { + // Try to infer type from value (for struct { } bindings) + match self.analyze_expr(value) { + Ok(ty) => ty, + Err(e) => { + self.errors.push(e); + Type::Unknown + } + } + } else { + Type::Unknown // Will be inferred + }; + + // If binding a struct/enum type, register as TypeDef + if let Type::Struct { fields, .. } = &ty { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Struct { + name: binding.name.clone(), + fields: fields.clone(), + }, + }, + location: binding.location.clone(), + })?; + } else if let Type::Enum { variants, .. } = &ty { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Enum { + name: binding.name.clone(), + variants: variants.clone(), + }, + }, + location: binding.location.clone(), + })?; + } else { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::Variable { + ty, + is_const: !binding.is_mutable, + is_comptime: !binding.is_mutable, // Top-level immutable bindings are comptime + }, + location: binding.location.clone(), + })?; + } + } + TopLevelDecl::Test(_) => { + // Tests don't declare symbols + } + TopLevelDecl::DeclareGate(gate) => { + // Register an opaque target gate (reject duplicates) + self.register_user_gate( + &gate.name, + gate.params.len(), + gate.qubits.len(), + GateOrigin::TargetDeclared, + )?; + } + TopLevelDecl::Gate(gate) => { + // Register a composite gate definition (reject duplicates) + self.register_user_gate( + &gate.name, + gate.params.len(), + gate.qubits.len(), + GateOrigin::CompositeDefined, + )?; + } + TopLevelDecl::ErrorSet(error_set) => { + // Error sets define a type containing the error values with optional associated data + let errors: Vec<(String, Option>)> = error_set + .variants + .iter() + .map(|v| { + let data_type = v + .data_type + .as_ref() + .map(|ty| Box::new(self.resolve_type(ty))); + (v.name.clone(), data_type) + }) + .collect(); + + self.symbols.define(Symbol { + name: error_set.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::ErrorSet { + name: error_set.name.clone(), + errors, + }, + }, + location: error_set.location.clone(), + })?; + } + TopLevelDecl::FaultSet(fault_set) => { + // Fault sets define a type containing the fault values with optional associated data + let faults: Vec<(String, Option>)> = fault_set + .variants + .iter() + .map(|v| { + let data_type = v + .data_type + .as_ref() + .map(|ty| Box::new(self.resolve_type(ty))); + (v.name.clone(), data_type) + }) + .collect(); + + self.symbols.define(Symbol { + name: fault_set.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::FaultSet { + name: fault_set.name.clone(), + faults, + }, + }, + location: fault_set.location.clone(), + })?; + } + TopLevelDecl::Union(union_decl) => { + // Union defines a tagged union type + let fields: Vec<(String, Option)> = union_decl + .fields + .iter() + .map(|f| (f.name.clone(), f.ty.as_ref().map(|t| self.resolve_type(t)))) + .collect(); + + // tag: None = untagged, Some(None) = auto-tagged, Some(Some(_)) = external tag + let is_tagged = union_decl.tag.is_some(); + + self.symbols.define(Symbol { + name: union_decl.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Union { + name: union_decl.name.clone(), + fields, + is_tagged, + }, + }, + location: union_decl.location.clone(), + })?; + } + TopLevelDecl::ExternFn(extern_fn) => { + // External functions are registered like regular functions + let params: Vec<(String, Type)> = extern_fn + .params + .iter() + .map(|p| (p.name.clone(), self.resolve_type(&p.ty))) + .collect(); + let return_type = extern_fn + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + + self.symbols.define(Symbol { + name: extern_fn.name.clone(), + kind: SymbolKind::Function { + params, + return_type, + is_pub: extern_fn.is_pub, + comptime_param_indices: vec![], // Extern functions don't support comptime params + original_decl: None, + }, + location: extern_fn.location.clone(), + })?; + } + } + Ok(()) + } + + /// Analyze a top-level declaration (full analysis pass). + fn analyze_top_level(&mut self, decl: &TopLevelDecl) -> SemanticResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) => self.analyze_fn(fn_decl), + TopLevelDecl::Struct(struct_decl) => self.analyze_struct(struct_decl), + TopLevelDecl::Binding(binding) => self.analyze_binding(binding), + TopLevelDecl::Test(test_decl) => self.analyze_block(&test_decl.body), + TopLevelDecl::Enum(_) => Ok(()), // Enums are fully analyzed in collect pass + TopLevelDecl::Union(_) => Ok(()), // Unions are fully analyzed in collect pass + TopLevelDecl::ErrorSet(_) => Ok(()), // Error sets are fully analyzed in collect pass + TopLevelDecl::FaultSet(_) => Ok(()), // Fault sets are fully analyzed in collect pass + TopLevelDecl::ExternFn(_) => Ok(()), // Extern functions are fully analyzed in collect pass + TopLevelDecl::DeclareGate(_) => Ok(()), // Fully analyzed in collect pass + TopLevelDecl::Gate(gate) => { + // Analyze composite gate body in a scope with qubit/param bindings + self.symbols.push_scope(ScopeKind::Function)?; + for qp in &gate.qubits { + self.symbols.define(Symbol { + name: qp.name.clone(), + kind: SymbolKind::Variable { + ty: Type::Qubit, + is_const: false, + is_comptime: false, + }, + location: qp.location.clone(), + })?; + } + for gp in &gate.params { + self.symbols.define(Symbol { + name: gp.name.clone(), + kind: SymbolKind::Variable { + ty: Type::A64, // Gate params are angles by default + is_const: false, + is_comptime: false, + }, + location: gp.location.clone(), + })?; + } + let result = self.analyze_block(&gate.body); + self.symbols.pop_scope(); + result + } + } + } + + /// Analyze a function declaration. + fn analyze_fn(&mut self, fn_decl: &FnDecl) -> SemanticResult<()> { + self.symbols.push_scope(ScopeKind::Function)?; + + // Set current function context + let prev_function = self.current_function.take(); + self.current_function = Some(fn_decl.name.clone()); + + // Track function in call stack for recursion detection (always enforced) + self.recursion_tracker + .enter_function(&fn_decl.name, &fn_decl.location.clone().unwrap_or_default())?; + + // Define parameters + for param in &fn_decl.params { + let ty = self.resolve_type(¶m.ty); + self.symbols.define(Symbol { + name: param.name.clone(), + kind: SymbolKind::Parameter { + ty: ty.clone(), + is_comptime: param.is_comptime, + }, + location: param.location.clone(), + })?; + } + + // Set return type for return statement checking + let return_type = fn_decl.return_type.as_ref().map(|t| self.resolve_type(t)); + self.current_return_type = return_type.clone(); + + // Analyze body + self.analyze_block(&fn_decl.body)?; + + // Check that all functions have explicit returns on all paths + // (NASA Power of 10: explicit control flow) + // - Unit functions must have explicit `return unit;` + // - Non-unit functions must have explicit `return expr;` + // - Never functions are exempt (they never return normally) + let is_never = matches!(&return_type, Some(Type::Never)); + if !is_never && !self.block_always_returns(&fn_decl.body) { + return Err(SemanticError::MissingReturn { + name: fn_decl.name.clone(), + location: fn_decl.location.clone().unwrap_or_default(), + }); + } + + // Exit recursion tracker (always enforced) + self.recursion_tracker.exit_function(&fn_decl.name); + + // Restore previous function context + self.current_return_type = None; + self.current_function = prev_function; + self.symbols.pop_scope(); + Ok(()) + } + + /// Analyze a struct declaration. + fn analyze_struct(&mut self, struct_decl: &StructDecl) -> SemanticResult<()> { + // Analyze default field values + for field in &struct_decl.fields { + if let Some(default) = &field.default { + let field_ty = self.resolve_type(&field.ty); + let expr_ty = self.analyze_expr(default)?; + self.check_assignable(&field_ty, &expr_ty, field.location.clone())?; + } + } + + // Analyze methods + for method in &struct_decl.methods { + self.analyze_fn(method)?; + } + + Ok(()) + } + + /// Check if a block always returns (all code paths have explicit return). + /// This enforces explicit returns - trailing expressions don't count. + fn block_always_returns(&self, block: &Block) -> bool { + // Check each statement - if any always returns, the block returns + for stmt in &block.statements { + if self.stmt_always_returns(stmt) { + return true; + } + } + // Also check trailing expression - if it always returns, block returns + // (e.g., `if (cond) { return 1; } else { return 2; }` as trailing expr) + if let Some(trailing) = &block.trailing_expr + && self.expr_always_returns(trailing) + { + return true; + } + false + } + + /// Check if a statement always returns. + fn stmt_always_returns(&self, stmt: &Stmt) -> bool { + match stmt { + Stmt::Return(_) => true, + + Stmt::If(if_stmt) => { + // If with else - both branches must return + if let Some(else_branch) = &if_stmt.else_body { + let then_returns = self.block_always_returns(&if_stmt.then_body); + let else_returns = self.else_branch_always_returns(else_branch); + then_returns && else_returns + } else { + // If without else - doesn't guarantee return + false + } + } + + Stmt::Block(block) => self.block_always_returns(block), + + Stmt::Switch(switch_stmt) => { + // All prongs must return, and there must be an else prong + let has_else = switch_stmt.prongs.iter().any(|p| p.is_else); + if !has_else { + return false; + } + switch_stmt + .prongs + .iter() + .all(|p| self.expr_always_returns(&p.body)) + } + + Stmt::For(for_stmt) => { + // For loop body might not execute at all + // Even if body returns, loop might have 0 iterations + // But if body has unreachable return, we can't reach after loop + // For simplicity, assume for loops don't guarantee return + let _ = for_stmt; + false + } + + // Expression statements might contain if expressions that return + Stmt::Expr(expr_stmt) => self.expr_always_returns(&expr_stmt.expr), + + // Other statements don't return + Stmt::Binding(_) + | Stmt::Alias(_) + | Stmt::Assign(_) + | Stmt::Tick(_) + | Stmt::TryBlock(_) + | Stmt::Break(_) + | Stmt::Continue(_) + | Stmt::Defer(_) + | Stmt::Errdefer(_) + | Stmt::Gate(_) + | Stmt::Prepare(_) + | Stmt::Measure(_) + | Stmt::Barrier(_) => false, + } + } + + /// Check if an else branch always returns. + fn else_branch_always_returns(&self, else_branch: &ElseBranch) -> bool { + match else_branch { + ElseBranch::ElseIf(nested_if) => { + // Recursively check the nested if statement + if let Some(else_body) = &nested_if.else_body { + let then_returns = self.block_always_returns(&nested_if.then_body); + let else_returns = self.else_branch_always_returns(else_body); + then_returns && else_returns + } else { + // else if without else - doesn't guarantee return + false + } + } + ElseBranch::Else(block) => self.block_always_returns(block), + } + } + + /// Check if an expression always returns. + /// This handles block expressions that might contain return statements. + fn expr_always_returns(&self, expr: &Expr) -> bool { + match expr { + Expr::Block(block_expr) => { + // Check statements in the block expression + for stmt in &block_expr.statements { + if self.stmt_always_returns(stmt) { + return true; + } + } + // Trailing expression doesn't count as return + false + } + Expr::If(if_expr) => { + // If expression always has both branches (ternary form) + // Both must return for the expression to always return + self.expr_always_returns(&if_expr.then_expr) + && self.expr_always_returns(&if_expr.else_expr) + } + // Most expressions don't contain return statements + _ => false, + } + } + + /// Analyze a binding declaration. + fn analyze_binding(&mut self, binding: &Binding) -> SemanticResult<()> { + if let Some(value) = &binding.value { + let expr_ty = self.analyze_expr(value)?; + + if let Some(type_expr) = &binding.ty { + let declared_ty = self.resolve_type(type_expr); + self.check_assignable(&declared_ty, &expr_ty, binding.location.clone())?; + } + } + + Ok(()) + } + + /// Analyze a block. + fn analyze_block(&mut self, block: &Block) -> SemanticResult<()> { + self.symbols.push_scope(ScopeKind::Block)?; + + for stmt in &block.statements { + self.analyze_stmt(stmt)?; + } + + self.symbols.pop_scope(); + Ok(()) + } + + /// Analyze a statement. + fn analyze_stmt(&mut self, stmt: &Stmt) -> SemanticResult<()> { + match stmt { + Stmt::Binding(binding) => { + let ty = if let Some(value) = &binding.value { + let expr_ty = self.analyze_expr(value)?; + + // Register allocator if this is a qalloc() call + // (gates don't require mut, only .child() does) + if let Some(capacity) = self.try_extract_allocator_capacity(value) { + self.qubit_states + .register_allocator(AllocatorInfo::new(&binding.name, capacity)); + } + // Register child allocator if this is base.child(n) + else if let Some((parent, capacity)) = self.try_extract_child_allocator(value) + { + // Check that the parent allocator is mutable + if let Some(symbol) = self.symbols.lookup(&parent) + && let SymbolKind::Variable { is_const: true, .. } = &symbol.kind + { + return Err(SemanticError::ChildRequiresMutableParent { + name: parent, + location: binding.location.clone().unwrap_or_default(), + }); + } + self.qubit_states.register_allocator(AllocatorInfo::child( + &binding.name, + parent, + capacity, + )); + } + + // Try to evaluate immutable bindings at comptime + if !binding.is_mutable { + let mut evaluator = ComptimeEvaluator::new(); + // Populate evaluator context with existing comptime values + for (name, comptime_val) in &self.comptime_values { + evaluator.context.define(name, comptime_val.clone()); + } + if let Ok(comptime_val) = evaluator.eval_expr(value) { + self.comptime_values + .insert(binding.name.clone(), comptime_val); + } + } + + if let Some(type_expr) = &binding.ty { + let declared_ty = self.resolve_type(type_expr); + self.check_assignable(&declared_ty, &expr_ty, binding.location.clone())?; + declared_ty + } else { + expr_ty + } + } else if let Some(type_expr) = &binding.ty { + self.resolve_type(type_expr) + } else { + return Err(SemanticError::CannotInferType { + name: binding.name.clone(), + location: binding.location.clone().unwrap_or_default(), + }); + }; + + // If binding a struct/enum type, also register as TypeDef for type resolution + // This allows: Syndrome := struct { x: u8 }; mz(pack Syndrome) [...] + if let Type::Struct { fields, .. } = &ty { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Struct { + name: binding.name.clone(), + fields: fields.clone(), + }, + }, + location: binding.location.clone(), + })?; + } else if let Type::Enum { variants, .. } = &ty { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Enum { + name: binding.name.clone(), + variants: variants.clone(), + }, + }, + location: binding.location.clone(), + })?; + } else { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::Variable { + ty, + is_const: !binding.is_mutable, + is_comptime: false, + }, + location: binding.location.clone(), + })?; + } + } + Stmt::Alias(alias) => { + self.analyze_alias(alias)?; + } + Stmt::Assign(assign) => { + // Check mutability before allowing assignment + self.check_assignment_target_mutable(&assign.target, &assign.location)?; + + let target_ty = self.analyze_expr(&assign.target)?; + let value_ty = self.analyze_expr(&assign.value)?; + self.check_assignable(&target_ty, &value_ty, assign.location.clone())?; + } + Stmt::If(if_stmt) => { + let cond_ty = self.analyze_expr(&if_stmt.condition)?; + + // Check if this is an optional unwrap pattern: if (opt) |value| { ... } + if let Some(capture_name) = &if_stmt.capture { + // Condition must be an optional type + if let Type::Optional { inner } = &cond_ty { + // Create a new scope for the then block with the capture variable + self.symbols.push_scope(ScopeKind::Block)?; + self.symbols.define(Symbol { + name: capture_name.clone(), + kind: SymbolKind::Variable { + ty: *inner.clone(), + is_const: true, + is_comptime: false, + }, + location: if_stmt.location.clone(), + })?; + self.analyze_block(&if_stmt.then_body)?; + self.symbols.pop_scope(); + } else { + return Err(SemanticError::TypeMismatch { + expected: "optional type (?T)".to_string(), + found: cond_ty.display_name(), + location: if_stmt.location.clone().unwrap_or_default(), + }); + } + } else { + // Regular if statement - condition must be bool + self.check_assignable(&Type::Bool, &cond_ty, if_stmt.location.clone())?; + self.analyze_block(&if_stmt.then_body)?; + } + + // Analyze else branch (no capture variable here) + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + ast::ElseBranch::ElseIf(else_if) => { + self.analyze_stmt(&Stmt::If(*else_if.clone()))?; + } + ast::ElseBranch::Else(block) => { + self.analyze_block(block)?; + } + } + } + } + Stmt::For(for_stmt) => { + self.symbols.push_scope(ScopeKind::Loop)?; + self.loop_depth += 1; + + // Inline for loops require comptime-evaluable ranges + if for_stmt.is_inline { + self.inline_for_depth += 1; + // Validate that range bounds are comptime-evaluable + match &for_stmt.range { + ForRange::Range { start, end } => { + let mut evaluator = ComptimeEvaluator::new(); + // Populate evaluator with known comptime values + for (name, val) in &self.comptime_values { + evaluator.context.define(name, val.clone()); + } + if evaluator.eval_expr(start).is_err() { + return Err(SemanticError::InlineForRangeNotComptime { + expr: format!("{:?}", start), + location: for_stmt.location.clone().unwrap_or_default(), + }); + } + if evaluator.eval_expr(end).is_err() { + return Err(SemanticError::InlineForRangeNotComptime { + expr: format!("{:?}", end), + location: for_stmt.location.clone().unwrap_or_default(), + }); + } + } + ForRange::Collection(expr) => { + let mut evaluator = ComptimeEvaluator::new(); + for (name, val) in &self.comptime_values { + evaluator.context.define(name, val.clone()); + } + if evaluator.eval_expr(expr).is_err() { + return Err(SemanticError::InlineForRangeNotComptime { + expr: format!("{:?}", expr), + location: for_stmt.location.clone().unwrap_or_default(), + }); + } + } + } + } + + // Infer type from range expression + let capture_type = self.infer_for_range_type(&for_stmt.range)?; + + // Define capture variables with inferred type + for capture in &for_stmt.captures { + self.symbols.define(Symbol { + name: capture.clone(), + kind: SymbolKind::Variable { + ty: capture_type.clone(), + is_const: true, + is_comptime: for_stmt.is_inline, + }, + location: for_stmt.location.clone(), + })?; + } + + self.analyze_block(&for_stmt.body)?; + + if for_stmt.is_inline { + self.inline_for_depth -= 1; + } + self.loop_depth -= 1; + self.symbols.pop_scope(); + } + Stmt::Switch(switch_stmt) => { + let _value_ty = self.analyze_expr(&switch_stmt.value)?; + + // Track seen case values for duplicate detection + let mut seen_cases: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + + for prong in &switch_stmt.prongs { + for case in &prong.cases { + self.analyze_expr(&case.value)?; + + // Try to get a string representation for duplicate detection + // For literals and simple expressions, use their string form + let case_key = self.case_value_key(&case.value); + if let Some(key) = case_key + && !seen_cases.insert(key.clone()) + { + return Err(SemanticError::DuplicateSwitchCase { + value: key, + location: case.location.clone().unwrap_or_default(), + }); + } + } + self.analyze_expr(&prong.body)?; + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + let value_ty = self.analyze_expr(value)?; + if let Some(expected) = &self.current_return_type { + self.check_assignable(expected, &value_ty, ret.location.clone())?; + } + // Check for escaping references to local variables + // This is always enforced (safe-by-constraint memory model) + self.check_no_local_escape(value, ret.location.clone().unwrap_or_default())?; + } else { + // `return;` without a value is only allowed for unit functions + // (equivalent to `return unit;`) + if let Some(expected) = &self.current_return_type + && !matches!(expected, Type::Unit) + { + return Err(SemanticError::ReturnWithoutValue { + expected: expected.display_name(), + location: ret.location.clone().unwrap_or_default(), + }); + } + // If no return type specified, unit is implied - return; is valid + } + } + Stmt::Break(break_stmt) => { + if self.loop_depth == 0 { + return Err(SemanticError::BreakContinueOutsideLoop { + keyword: "break".to_string(), + location: break_stmt.location.clone().unwrap_or_default(), + }); + } + // break is not allowed in inline for loops + if self.inline_for_depth > 0 { + return Err(SemanticError::BreakInInlineFor { + location: break_stmt.location.clone().unwrap_or_default(), + }); + } + // Analyze break value if present + if let Some(value) = &break_stmt.value { + self.analyze_expr(value)?; + } + } + Stmt::Continue(continue_stmt) => { + if self.loop_depth == 0 { + return Err(SemanticError::BreakContinueOutsideLoop { + keyword: "continue".to_string(), + location: continue_stmt.location.clone().unwrap_or_default(), + }); + } + // continue is not allowed in inline for loops + if self.inline_for_depth > 0 { + return Err(SemanticError::ContinueInInlineFor { + location: continue_stmt.location.clone().unwrap_or_default(), + }); + } + } + Stmt::Defer(defer) => { + self.analyze_stmt(&defer.body)?; + } + Stmt::Errdefer(errdefer) => { + // If there's a capture, add it to scope for the body + if let Some(capture_name) = &errdefer.capture { + self.symbols.push_scope(ScopeKind::Block)?; + // The captured error has type anyerror (could be any error type) + self.symbols.define(Symbol { + name: capture_name.clone(), + kind: SymbolKind::Variable { + ty: Type::AnyError, + is_const: true, + is_comptime: false, + }, + location: errdefer.location.clone(), + })?; + self.analyze_stmt(&errdefer.body)?; + self.symbols.pop_scope(); + } else { + self.analyze_stmt(&errdefer.body)?; + } + } + Stmt::Block(block) => { + self.analyze_block(block)?; + } + Stmt::Expr(expr_stmt) => { + self.analyze_expr(&expr_stmt.expr)?; + } + Stmt::Gate(gate_op) => { + // Validate gate arity + if gate_op.targets.len() != gate_op.kind.arity() { + return Err(SemanticError::GateArityMismatch { + gate: format!("{:?}", gate_op.kind), + expected: gate_op.kind.arity(), + found: gate_op.targets.len(), + location: gate_op.location.clone().unwrap_or_default(), + }); + } + + // Validate targets are valid qubit references and check state + for target in &gate_op.targets { + self.validate_qubit_ref(target)?; + + // In strict mode, verify qubits are prepared + if self.strict_mode { + // Try to extract constant index for state tracking + if let Some(index) = self.try_extract_constant_usize(&target.index) { + let location = gate_op.location.clone().unwrap_or_default(); + self.qubit_states.validate_for_gate( + &target.allocator, + index, + &location, + )?; + } + // If index is not constant, we can't track state at compile time + // (runtime checking would be needed) + } + } + + // Validate parameters for parameterized gates + for param in &gate_op.params { + let param_ty = self.analyze_expr(param)?; + if !param_ty.is_float() && param_ty != Type::Unknown { + self.errors.push(SemanticError::TypeMismatch { + expected: "float".to_string(), + found: param_ty.display_name(), + location: gate_op.location.clone().unwrap_or_default(), + }); + } + } + } + Stmt::Prepare(prepare_op) => { + // Validate allocator exists + if self.symbols.lookup(&prepare_op.allocator).is_none() { + return Err(SemanticError::AllocatorNotFound { + name: prepare_op.allocator.clone(), + location: prepare_op.location.clone().unwrap_or_default(), + }); + } + + // Track state transitions + if let Some(alloc) = self.qubit_states.get_allocator_mut(&prepare_op.allocator) { + if let Some(slots) = &prepare_op.slots { + // Prepare specific slots + for &slot in slots { + let slot_usize = slot as usize; + if self.strict_mode { + if let Err((idx, _state)) = alloc.prepare_slot(slot_usize) { + // Slot out of bounds or already prepared + if !alloc.is_in_bounds(idx) { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: prepare_op.allocator.clone(), + index: idx, + capacity: alloc.capacity.unwrap_or(0), + location: prepare_op + .location + .clone() + .unwrap_or_default(), + }); + } + } + } else { + let _ = alloc.prepare_slot(slot_usize); + } + } + } else { + // Prepare all slots + alloc.prepare_all(); + } + } + } + Stmt::Measure(measure_op) => { + for target in &measure_op.targets { + self.validate_qubit_ref(target)?; + + // Try to extract constant index for state tracking + if let Some(index) = self.try_extract_constant_usize(&target.index) { + // In strict mode, verify qubits are prepared before measurement + if self.strict_mode { + let location = target.location.clone().unwrap_or_default(); + self.qubit_states.validate_for_gate( + &target.allocator, + index, + &location, + )?; + } + + // Transition to unprepared after measurement + if let Some(alloc) = self.qubit_states.get_allocator_mut(&target.allocator) + { + alloc.measure_slot(index); + } + } + // If index is not constant, we can't track state at compile time + } + } + Stmt::Barrier(_) => { + // Barriers are always valid + } + Stmt::Tick(tick_stmt) => { + // Tick blocks represent parallel gate layers - no nesting allowed + if self.tick_depth > 0 { + return Err(SemanticError::NestedTick { + location: tick_stmt.location.clone().unwrap_or_default(), + }); + } + + // In strict mode, validate that no qubit is used twice within a tick + if self.strict_mode { + self.check_duplicate_qubits_in_tick(&tick_stmt.body, &tick_stmt.location)?; + } + + // Track tick depth and analyze statements + self.tick_depth += 1; + for stmt in &tick_stmt.body { + self.analyze_stmt(stmt)?; + } + self.tick_depth -= 1; + } + Stmt::TryBlock(try_block) => { + // Analyze statements within the try block body + for stmt in &try_block.body.statements { + self.analyze_stmt(stmt)?; + } + // Analyze trailing expression if present + if let Some(trailing) = &try_block.body.trailing_expr { + self.analyze_expr(trailing)?; + } + // Analyze catch clause if present + if let Some(catch_clause) = &try_block.catch_clause { + // The catch variable is in scope for the catch body + // For now, just analyze the body expression + self.analyze_expr(&catch_clause.body)?; + } + } + } + Ok(()) + } + + /// Analyze an expression and return its type. + fn analyze_expr(&mut self, expr: &Expr) -> SemanticResult { + match expr { + Expr::IntLit(lit) => { + // Use suffix type if present, otherwise default to i64 + if let Some(suffix) = &lit.suffix { + Ok(int_suffix_to_type(suffix)) + } else { + Ok(Type::IInt { + bits: BitWidth::BITS_64, + }) + } + } + Expr::FloatLit(lit) => { + // Use suffix type if present, otherwise default to f64 + if let Some(suffix) = &lit.suffix { + Ok(float_suffix_to_type(suffix)) + } else { + Ok(Type::F64) + } + } + Expr::AngleLit(angle) => { + // Analyze the inner value expression + let inner_type = self.analyze_expr(&angle.value)?; + // Inner must be numeric + if !matches!( + inner_type, + Type::IInt { .. } + | Type::UInt { .. } + | Type::F32 + | Type::F64 + | Type::F16 + | Type::F128 + ) { + return Err(SemanticError::TypeMismatch { + expected: "numeric".to_string(), + found: format!("{:?}", inner_type), + location: angle.location.clone().unwrap_or_default(), + }); + } + // Angle literals have type a64 + Ok(Type::A64) + } + Expr::TypeAscription(asc) => { + // Analyze the inner value expression + let _inner_type = self.analyze_expr(&asc.value)?; + // Parse the type name and return that type + match asc.type_name.as_str() { + "f16" => Ok(Type::F16), + "f32" => Ok(Type::F32), + "f64" => Ok(Type::F64), + "f128" => Ok(Type::F128), + "a64" => Ok(Type::A64), + "u8" => Ok(Type::UInt { bits: BitWidth::BITS_8 }), + "u16" => Ok(Type::UInt { bits: BitWidth::BITS_16 }), + "u32" => Ok(Type::UInt { bits: BitWidth::BITS_32 }), + "u64" => Ok(Type::UInt { bits: BitWidth::BITS_64 }), + "u128" => Ok(Type::UInt { bits: BitWidth::BITS_128 }), + "usize" => Ok(Type::Usize), + "i8" => Ok(Type::IInt { bits: BitWidth::BITS_8 }), + "i16" => Ok(Type::IInt { bits: BitWidth::BITS_16 }), + "i32" => Ok(Type::IInt { bits: BitWidth::BITS_32 }), + "i64" => Ok(Type::IInt { bits: BitWidth::BITS_64 }), + "i128" => Ok(Type::IInt { bits: BitWidth::BITS_128 }), + "isize" => Ok(Type::Isize), + _ => Err(SemanticError::TypeMismatch { + expected: "valid numeric type suffix (u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, a64, etc.)".to_string(), + found: asc.type_name.clone(), + location: asc.location.clone().unwrap_or_default(), + }), + } + } + Expr::BoolLit(_) => Ok(Type::Bool), + Expr::StringLit(_) => Ok(Type::Slice { + element: Box::new(Type::UInt { + bits: BitWidth::BITS_8, + }), + }), + Expr::FString(fstr) => { + // Analyze all interpolated expressions for errors + for part in &fstr.parts { + if let FStringPart::Expr { expr, format: _ } = part { + self.analyze_expr(expr)?; + } + } + // F-strings produce string slices + Ok(Type::Slice { + element: Box::new(Type::UInt { + bits: BitWidth::BITS_8, + }), + }) + } + Expr::CharLit(_) => Ok(Type::UInt { + bits: BitWidth::BITS_8, + }), + Expr::Null(_) => Ok(Type::Optional { + inner: Box::new(Type::Unknown), + }), + Expr::Undefined(_) => Ok(Type::Unknown), + Expr::Unit(_) => Ok(Type::Unit), + + Expr::Ident(ident) => { + if let Some(symbol) = self.symbols.lookup(&ident.name) { + match &symbol.kind { + SymbolKind::Variable { ty, .. } => Ok(ty.clone()), + SymbolKind::Parameter { ty, .. } => Ok(ty.clone()), + SymbolKind::Function { + params, + return_type, + .. + } => Ok(Type::Function { + params: params.iter().map(|(_, t)| t.clone()).collect(), + return_type: Box::new(return_type.clone()), + }), + SymbolKind::TypeDef { ty } => Ok(ty.clone()), + SymbolKind::Allocator { capacity } => Ok(Type::Allocator { + capacity: *capacity, + }), + } + } else { + // Check if it's a built-in constant + if is_builtin_constant(&ident.name) { + Ok(get_builtin_constant_type(&ident.name)) + // Check if it's a built-in gate name + } else if is_gate_name(&ident.name) { + Ok(Type::Function { + params: vec![Type::Qubit], + return_type: Box::new(Type::Unit), + }) + // Check if it's a built-in type name (for comptime type values) + } else if resolve_builtin_type_name(&ident.name).is_some() { + Ok(Type::Type) // The expression evaluates to a type value + } else { + Err(SemanticError::UndefinedSymbol { + name: ident.name.clone(), + location: ident.location.clone().unwrap_or_default(), + }) + } + } + } + + Expr::Binary(binary) => { + let left_ty = self.analyze_expr(&binary.left)?; + let right_ty = self.analyze_expr(&binary.right)?; + self.check_binary_op(binary.op, &left_ty, &right_ty, binary.location.clone()) + } + + Expr::Unary(unary) => { + let operand_ty = self.analyze_expr(&unary.operand)?; + self.check_unary_op(unary.op, &operand_ty, unary.location.clone()) + } + + Expr::Call(call) => { + let callee_ty = self.analyze_expr(&call.callee)?; + + // Reject gate names used with call syntax - must use gate expression syntax + if let Expr::Ident(ident) = &call.callee { + if ident.name == "mz" { + // Reject old syntax: mz(type, target) - should use mz(T) target + return Err(SemanticError::DeprecatedMeasurementSyntax { + location: call.location.clone().unwrap_or_default(), + }); + } + if let Some(info) = get_gate_info(&ident.name) { + // Gate names cannot be called with function syntax + // Generate helpful hint based on gate type + let hint = if info.parameterized { + match info.arity { + 1 => "() q[i]".to_string(), + 2 => "() (q[i], q[j])".to_string(), + _ => "() (q[...])".to_string(), + } + } else { + match info.arity { + 1 => "q[i]".to_string(), + 2 => "(q[i], q[j])".to_string(), + 3 => "(q[i], q[j], q[k])".to_string(), + _ => "(q[...])".to_string(), + } + }; + return Err(SemanticError::InvalidGateSyntax { + gate: ident.name.clone(), + hint, + location: call.location.clone().unwrap_or_default(), + }); + } + + // Recursion is never allowed (NASA Power of 10 compliance) + // Use FFI with Rust if complex recursive algorithms are needed + // Check for both direct and mutual recursion using the call stack + if self.recursion_tracker.is_in_call_stack(&ident.name) { + return Err(SemanticError::RecursionDetected { + name: ident.name.clone(), + location: call.location.clone().unwrap_or_default(), + }); + } + + // Record call in call graph for mutual recursion detection + if let Some(ref caller) = self.current_function + && self.user_functions.contains(&ident.name) + { + self.call_graph + .entry(caller.clone()) + .or_default() + .insert(ident.name.clone()); + } + + // Check if this is a generic function that needs instantiation + // First, extract all needed data from the immutable borrow + let generic_info = self.symbols.lookup(&ident.name).and_then(|symbol| { + if let SymbolKind::Function { + comptime_param_indices, + original_decl, + return_type: fn_return_type, + .. + } = &symbol.kind + && !comptime_param_indices.is_empty() + && let Some(original) = original_decl + { + return Some(( + comptime_param_indices.clone(), + *original.clone(), + fn_return_type.clone(), + )); + } + None + }); + + // Now we can use the extracted data with a mutable borrow + if let Some((comptime_param_indices, original_decl, fn_return_type)) = + generic_info + { + // Evaluate comptime arguments + let mut comptime_args = Vec::new(); + for &idx in &comptime_param_indices { + if idx < call.args.len() { + // Try to evaluate the argument at comptime + match self.comptime.eval_expr(&call.args[idx]) { + Ok(val) => comptime_args.push(val), + Err(e) => { + return Err(SemanticError::ComptimeError { + message: format!( + "comptime argument {} must be evaluable at compile time: {}", + idx, e + ), + location: call.args[idx] + .get_location() + .unwrap_or_default(), + }); + } + } + } + } + + // Instantiate the generic function + let _mangled_name = self.instantiate_generic_function( + &ident.name, + &comptime_args, + &comptime_param_indices, + &original_decl, + )?; + + // For now, return the original return type + // (Full specialization would substitute types too) + return Ok(fn_return_type); + } + } + + match callee_ty { + Type::Function { + params, + return_type, + } => { + // Validate argument count + if call.args.len() != params.len() { + return Err(SemanticError::ArgumentCountMismatch { + expected: params.len(), + found: call.args.len(), + location: call.location.clone().unwrap_or_default(), + }); + } + + // Validate argument types + for (arg, param_ty) in call.args.iter().zip(params.iter()) { + let arg_ty = self.analyze_expr(arg)?; + if !self.types_compatible(&arg_ty, param_ty) { + return Err(SemanticError::TypeMismatch { + expected: param_ty.display_name(), + found: arg_ty.display_name(), + location: arg.get_location().unwrap_or_else(|| { + call.location.clone().unwrap_or_default() + }), + }); + } + } + + Ok(*return_type) + } + _ => Err(SemanticError::NotCallable { + location: call.location.clone().unwrap_or_default(), + }), + } + } + + Expr::BatchApply(batch) => { + // Batch apply: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } + // Analyze the operation and targets + self.analyze_expr(&batch.operation)?; + + // Extract gate name to check arity + let gate_name = match &batch.operation { + Expr::Ident(ident) => Some(ident.name.clone()), + Expr::Call(call) => { + if let Expr::Ident(ident) = &call.callee { + Some(ident.name.clone()) + } else { + None + } + } + _ => None, + }; + + // Validate target arity if we know the gate + if let Some(ref name) = gate_name + && let Some(info) = get_gate_info(name) + { + for target in &batch.targets { + let target_arity = self.count_target_elements(target); + if target_arity != info.arity { + return Err(SemanticError::GateArityMismatch { + gate: name.clone(), + expected: info.arity, + found: target_arity, + location: batch.location.clone().unwrap_or_default(), + }); + } + } + } + + for target in &batch.targets { + self.analyze_expr(target)?; + } + // Batch gate operations return unit + Ok(Type::Unit) + } + + Expr::Measure(measure) => { + // mz(T) target - per-qubit mode, count must match + // mz(pack T) target - pack mode, type must have enough bits + let location = measure.location.clone().unwrap_or_default(); + let result_type = self.resolve_type(&measure.result_type); + + // Count targets + let target_count = self.count_measurement_targets(&measure.targets)?; + + if measure.pack { + // Pack mode: bits fill the type, must have enough capacity + let bit_capacity = self.type_bit_size(&result_type); + + match bit_capacity { + Some(capacity) => { + if capacity < target_count { + return Err(SemanticError::MeasurementPackCapacity { + ty: result_type.display_name(), + capacity, + qubits: target_count, + location, + }); + } + } + None => { + // Pack mode requires compile-time verifiable bit size + return Err(SemanticError::MeasurementPackUnknownSize { + ty: result_type.display_name(), + location, + }); + } + } + + self.analyze_expr(&measure.targets)?; + self.validate_gate_target_bounds(&measure.targets)?; + Ok(result_type) + } else { + // Per-qubit mode: count must match exactly + match &result_type { + // Scalar type: must have exactly 1 target + Type::UInt { .. } => { + if target_count != 1 { + return Err(SemanticError::MeasurementArrayExpected { location }); + } + self.analyze_expr(&measure.targets)?; + self.validate_gate_target_bounds(&measure.targets)?; + Ok(result_type) + } + // Array type: size must be explicit and match target count + Type::Array { element, size } => { + // Validate element type is a valid measurement type (unsigned integer) + let is_valid_element = matches!(**element, Type::UInt { .. }); + if !is_valid_element { + return Err(SemanticError::InvalidMeasurementType { + ty: element.display_name(), + location, + }); + } + + // Size must be explicit (no [_]T inference for measurements) + let declared_size = match size { + Some(s) => *s, + None => { + return Err(SemanticError::InvalidMeasurementType { + ty: format!( + "[_]{} - use explicit size like [{}]{}", + element.display_name(), + target_count, + element.display_name() + ), + location, + }); + } + }; + + // Check size matches target count + if declared_size as usize != target_count { + return Err(SemanticError::MeasurementSizeMismatch { + declared: declared_size.to_string(), + element: element.display_name(), + actual: target_count, + location, + }); + } + + self.analyze_expr(&measure.targets)?; + self.validate_gate_target_bounds(&measure.targets)?; + Ok(Type::Array { + element: element.clone(), + size: Some(declared_size), + }) + } + // Struct type in per-qubit mode: each qubit produces one struct + Type::Struct { .. } => { + if target_count != 1 { + return Err(SemanticError::MeasurementArrayExpected { location }); + } + self.analyze_expr(&measure.targets)?; + self.validate_gate_target_bounds(&measure.targets)?; + Ok(result_type) + } + _ => Err(SemanticError::InvalidMeasurementType { + ty: result_type.display_name(), + location, + }), + } + } + } + + Expr::Gate(gate) => { + // gate target or gate(params) target + // Validate parameters for parameterized gates + let gate_kind = gate.kind; + if gate_kind.is_parameterized() { + if gate.params.is_empty() { + return Err(SemanticError::GateArityMismatch { + gate: format!("{:?}", gate_kind), + expected: 1, + found: 0, + location: gate.location.clone().unwrap_or_default(), + }); + } + // Analyze parameter expressions + for param in &gate.params { + self.analyze_expr(param)?; + } + } + + // For multi-qubit gates (arity > 1), reject bare allocator targets + // e.g., `cx q` is ambiguous - use `cx (q[0], q[1])` or `cx {(q[0], q[1]), ...}` + if gate_kind.arity() > 1 + && let Expr::Ident(ident) = &gate.target + { + // Check if this is an allocator + if let Some(symbol) = self.symbols.lookup(&ident.name) { + let is_allocator = match &symbol.kind { + SymbolKind::Variable { ty, .. } => matches!(ty, Type::Allocator { .. }), + SymbolKind::Allocator { .. } => true, + _ => false, + }; + if is_allocator { + return Err(SemanticError::AmbiguousGateTarget { + gate: format!("{:?}", gate_kind).to_lowercase(), + location: gate.location.clone().unwrap_or_default(), + }); + } + } + } + + // Check gate target arity + let expected_arity = gate_kind.arity(); + match &gate.target { + // Set literal: each element must have correct arity + Expr::Set(set) => { + for element in &set.elements { + let found_arity = self.count_target_elements(element); + if found_arity != expected_arity { + return Err(SemanticError::GateArityMismatch { + gate: format!("{:?}", gate_kind).to_lowercase(), + expected: expected_arity, + found: found_arity, + location: gate.location.clone().unwrap_or_default(), + }); + } + } + } + // Single target (qubit ref or tuple) + target => { + let found_arity = self.count_target_elements(target); + if found_arity != expected_arity { + return Err(SemanticError::GateArityMismatch { + gate: format!("{:?}", gate_kind).to_lowercase(), + expected: expected_arity, + found: found_arity, + location: gate.location.clone().unwrap_or_default(), + }); + } + } + } + + // Analyze target expression + self.analyze_expr(&gate.target)?; + + // Validate qubit bounds for gate targets + self.validate_gate_target_bounds(&gate.target)?; + + // Handle prepare gates specially (PZ resets qubits to |0⟩) + if gate_kind.is_prepare() { + // Prepare gates can be applied to unprepared qubits + // and transition them to the Prepared state + self.prepare_gate_targets(&gate.target); + } else if self.strict_mode { + // In strict mode, verify qubits are prepared before non-prepare gates + self.validate_gate_target_states( + &gate.target, + &gate.location.clone().unwrap_or_default(), + )?; + } + + // Gate operations are statements, return unit + Ok(Type::Unit) + } + + Expr::Field(field) => { + let object_ty = self.analyze_expr(&field.object)?; + match object_ty { + Type::Struct { fields, .. } => { + if let Some((_, ty)) = fields.iter().find(|(n, _)| n == &field.field) { + Ok(ty.clone()) + } else { + Err(SemanticError::UndefinedSymbol { + name: field.field.clone(), + location: field.location.clone().unwrap_or_default(), + }) + } + } + Type::Array { element, .. } => { + // Array properties + match field.field.as_str() { + "len" => Ok(Type::Usize), // Compile-time known length + "ptr" => Ok(Type::Pointer { + pointee: element, + is_const: false, + is_many: true, + }), + _ => Err(SemanticError::UndefinedSymbol { + name: field.field.clone(), + location: field.location.clone().unwrap_or_default(), + }), + } + } + Type::Slice { element } => { + // Slice properties + match field.field.as_str() { + "len" => Ok(Type::Usize), // Dynamic length + "ptr" => Ok(Type::Pointer { + pointee: element, + is_const: false, + is_many: true, + }), + _ => Err(SemanticError::UndefinedSymbol { + name: field.field.clone(), + location: field.location.clone().unwrap_or_default(), + }), + } + } + Type::Allocator { .. } => { + // Allocator methods + match field.field.as_str() { + "child" => Ok(Type::Function { + params: vec![Type::UInt { + bits: BitWidth::BITS_32, + }], + return_type: Box::new(Type::Allocator { capacity: None }), + }), + "release" => Ok(Type::Function { + params: vec![], + return_type: Box::new(Type::Unit), + }), + // Deprecated: use `pz q` or `pz {q[0], q[1]}` instead + "prepare_all" | "prepare" => Err(SemanticError::DeprecatedSyntax { + old: format!( + "{}.{}()", + if let Expr::Ident(id) = &field.object { + &id.name + } else { + "allocator" + }, + field.field + ), + new: if field.field == "prepare_all" { + "pz ".to_string() + } else { + "pz {q[i], q[j], ...}".to_string() + }, + location: field.location.clone().unwrap_or_default(), + }), + _ => Ok(Type::Unknown), + } + } + Type::Module { exports, .. } => { + // Module field access - look up exported symbol + if let Some((_, ty)) = exports.get(&field.field) { + Ok(ty.clone()) + } else { + Err(SemanticError::UndefinedSymbol { + name: field.field.clone(), + location: field.location.clone().unwrap_or_default(), + }) + } + } + _ => Ok(Type::Unknown), + } + } + + Expr::Index(index) => { + let object_ty = self.analyze_expr(&index.object)?; + let _index_ty = self.analyze_expr(&index.index)?; + + // Check if index is a range expression (slicing) + let is_slice_op = matches!(&index.index, Expr::Range(_)); + + match object_ty { + Type::Array { element, size } => { + if is_slice_op { + // arr[0..2] returns a slice + Ok(Type::Slice { element }) + } else { + // Bounds check: if both size and index are known at compile time + if let Some(n) = size + && let Some(idx) = self.try_extract_constant_usize(&index.index) + && idx >= n as usize + { + return Err(SemanticError::ArrayIndexOutOfBounds { + index: idx, + size: n, + location: index.location.clone().unwrap_or_default(), + }); + } + // arr[0] returns an element + Ok(*element) + } + } + Type::Slice { element } => { + if is_slice_op { + // slice[0..2] returns a slice (re-slicing) + Ok(Type::Slice { element }) + } else { + // slice[0] returns an element + Ok(*element) + } + } + Type::Allocator { .. } => Ok(Type::Qubit), + _ => Ok(Type::Unknown), + } + } + + Expr::If(if_expr) => { + let cond_ty = self.analyze_expr(&if_expr.condition)?; + self.check_assignable(&Type::Bool, &cond_ty, if_expr.location.clone())?; + + let then_ty = self.analyze_expr(&if_expr.then_expr)?; + let else_ty = self.analyze_expr(&if_expr.else_expr)?; + + if then_ty == else_ty { + Ok(then_ty) + } else { + Ok(Type::Unknown) // Could be improved with type unification + } + } + + Expr::Block(block) => { + self.symbols.push_scope(ScopeKind::Block)?; + for stmt in &block.statements { + self.analyze_stmt(stmt)?; + } + self.symbols.pop_scope(); + Ok(Type::Unknown) // Block expression type depends on break value + } + + Expr::Comptime(comptime) => { + // First, analyze the inner expression to get its type + let inner_ty = self.analyze_expr(&comptime.inner)?; + + // Evaluate the expression at compile time + match self.comptime.eval_expr(&comptime.inner) { + Ok(value) => { + // Store the evaluated value by location key + if let Some(loc) = &comptime.location { + let key = format!("{}:{}", loc.line, loc.column); + self.comptime_values.insert(key, value); + } + Ok(Type::Comptime(Box::new(inner_ty))) + } + Err(e) => { + // Comptime evaluation failed + let location = comptime + .location + .clone() + .unwrap_or_else(|| SourceLocation::new(0, 0)); + Err(SemanticError::ComptimeError { + message: e.message, + location, + }) + } + } + } + + Expr::Builtin(builtin) => { + match builtin.name.as_str() { + "import" => self.analyze_import(builtin), + "This" => Ok(Type::Type), + "sizeOf" => Ok(Type::Usize), + "typeInfo" => Ok(Type::Type), + "typeName" => Ok(Type::Slice { + element: Box::new(Type::UInt { + bits: BitWidth::BITS_8, + }), + }), + "swap" => { + // @swap(&a, &b) - swap two values in place + // Requires exactly 2 pointer arguments of the same type + if builtin.args.len() != 2 { + return Err(SemanticError::ArgumentCountMismatch { + expected: 2, + found: builtin.args.len(), + location: builtin.location.clone().unwrap_or_default(), + }); + } + let ty1 = self.analyze_expr(&builtin.args[0])?; + let ty2 = self.analyze_expr(&builtin.args[1])?; + // Both must be pointers to the same type + match (&ty1, &ty2) { + ( + Type::Pointer { pointee: e1, .. }, + Type::Pointer { pointee: e2, .. }, + ) => { + if e1 != e2 { + return Err(SemanticError::TypeMismatch { + expected: format!("*{:?}", e1), + found: format!("*{:?}", e2), + location: builtin.location.clone().unwrap_or_default(), + }); + } + } + (Type::Pointer { .. }, _) => { + return Err(SemanticError::TypeMismatch { + expected: "pointer".to_string(), + found: format!("{:?}", ty2), + location: builtin.location.clone().unwrap_or_default(), + }); + } + (_, Type::Pointer { .. }) => { + return Err(SemanticError::TypeMismatch { + expected: "pointer".to_string(), + found: format!("{:?}", ty1), + location: builtin.location.clone().unwrap_or_default(), + }); + } + _ => { + return Err(SemanticError::TypeMismatch { + expected: "pointer".to_string(), + found: format!("{:?}", ty1), + location: builtin.location.clone().unwrap_or_default(), + }); + } + } + Ok(Type::Unit) + } + _ => Ok(Type::Unknown), + } + } + + Expr::AnonStruct(anon) => { + // Anonymous struct type definition: struct { x: i32, y: i32 } + // This creates a type, not a value + let fields: Vec<(String, Type)> = anon + .fields + .iter() + .map(|f| (f.name.clone(), self.resolve_type(&f.ty))) + .collect(); + Ok(Type::Struct { + name: "".to_string(), + fields, + }) + } + + Expr::StructInit(init) => { + if let Some(ty) = &init.ty { + Ok(self.resolve_type(ty)) + } else { + // Anonymous struct initialization + let mut fields: Vec<(String, Type)> = Vec::with_capacity(init.fields.len()); + for f in &init.fields { + let ty = match self.analyze_expr(&f.value) { + Ok(ty) => ty, + Err(e) => { + self.errors.push(e); + Type::Unknown + } + }; + fields.push((f.name.clone(), ty)); + } + Ok(Type::Struct { + name: "".to_string(), + fields, + }) + } + } + + Expr::ArrayInit(init) => { + let element_type = if let Some(elem) = init.elements.first() { + self.analyze_expr(elem)? + } else { + Type::Unknown + }; + Ok(Type::Array { + element: Box::new(element_type), + size: Some(init.elements.len() as u64), + }) + } + + Expr::Range(_) => Ok(Type::Unknown), // Range type + Expr::SlotRef(_) => Ok(Type::Qubit), + Expr::BitRef(_) => Ok(Type::Bit), + + Expr::BracketArray(arr) => { + // Bracket array [a, b, c] - infer element type from first element + if arr.elements.is_empty() { + // In strict mode, empty arrays require explicit type annotation + if self.strict_mode { + return Err(SemanticError::EmptyArrayNeedsType { + location: arr.location.clone().unwrap_or_default(), + }); + } + Ok(Type::Slice { + element: Box::new(Type::Unknown), + }) + } else { + let element_ty = self.analyze_expr(&arr.elements[0])?; + // Analyze all elements for side effects/validation + for elem in arr.elements.iter().skip(1) { + let _ = self.analyze_expr(elem)?; + } + Ok(Type::Array { + element: Box::new(element_ty), + size: Some(arr.elements.len() as u64), + }) + } + } + + Expr::Tuple(tuple) => { + // Tuple (a, b) - analyze each element and build tuple type + let element_types: Result, SemanticError> = tuple + .elements + .iter() + .map(|elem| self.analyze_expr(elem)) + .collect(); + Ok(Type::Tuple { + elements: element_types?, + }) + } + + Expr::Set(set_expr) => { + // Set literal {a, b, c} - infer element type from first element + if set_expr.elements.is_empty() { + // Empty set - check if we have an explicit element type + if let Some(type_expr) = &set_expr.element_type { + let element_ty = self.resolve_type(type_expr); + Ok(Type::Set { + element: Box::new(element_ty), + }) + } else { + // In strict mode, empty sets require explicit type annotation + if self.strict_mode { + return Err(SemanticError::EmptySetNeedsType { + location: set_expr.location.clone().unwrap_or_default(), + }); + } + Ok(Type::Set { + element: Box::new(Type::Unknown), + }) + } + } else { + let element_ty = self.analyze_expr(&set_expr.elements[0])?; + // Analyze all elements for side effects/validation + for elem in set_expr.elements.iter().skip(1) { + let _ = self.analyze_expr(elem)?; + } + Ok(Type::Set { + element: Box::new(element_ty), + }) + } + } + + Expr::ErrorValue(err) => { + // Look up which error set contains this variant + if let Some(error_type) = self.symbols.find_error_set_by_variant(&err.name) { + Ok(error_type) + } else { + Err(SemanticError::UndefinedSymbol { + name: format!("error.{}", err.name), + location: err.location.clone().unwrap_or_default(), + }) + } + } + + Expr::FaultValue(fault) => { + // Look up which fault set contains this variant + if let Some(fault_type) = self.symbols.find_fault_set_by_variant(&fault.name) { + Ok(fault_type) + } else { + Err(SemanticError::UndefinedSymbol { + name: format!("fault.{}", fault.name), + location: fault.location.clone().unwrap_or_default(), + }) + } + } + + Expr::Catch(catch) => { + // catch expression: operand catch |err| handler + // Type is the payload type of the error union operand + let operand_ty = self.analyze_expr(&catch.operand)?; + let _handler_ty = self.analyze_expr(&catch.handler)?; + + // If operand is an error union T!E, the result is T + match operand_ty { + Type::ErrorUnion { payload, .. } => Ok(*payload), + Type::Unknown => Ok(Type::Unknown), // Allow Unknown for error recovery + _ => { + // Non-error-union with catch - this is an error + Err(SemanticError::CatchOnNonErrorType { + found: operand_ty.display_name(), + location: catch.location.clone().unwrap_or_default(), + }) + } + } + } + + Expr::TryBlock(try_block) => { + // Analyze the try block body + for stmt in &try_block.body.statements { + self.analyze_stmt(stmt)?; + } + + // Get the type of trailing expression (if any) + let body_type = if let Some(trailing) = &try_block.body.trailing_expr { + self.analyze_expr(trailing)? + } else { + Type::Unit + }; + + // Analyze catch clause if present + let catch_type = if let Some(catch_clause) = &try_block.catch_clause { + Some(self.analyze_expr(&catch_clause.body)?) + } else { + None + }; + + // Return type depends on mode and whether there's a catch clause + use crate::ast::TryMode; + match try_block.mode { + TryMode::Collect => { + // try {} (collect mode) -> []AnyError!T + // Collects all errors that occur during execution + if catch_type.is_some() { + // With catch, errors are handled - return array of results + Ok(Type::Slice { + element: Box::new(body_type), + }) + } else { + // Without catch, return error union array + Ok(Type::Slice { + element: Box::new(Type::ErrorUnion { + error: Box::new(Type::AnyError), + payload: Box::new(body_type), + }), + }) + } + } + TryMode::Propagate => { + // try! {} (propagate mode) -> E!T or T (if catch handles it) + if let Some(catch_ty) = catch_type { + // With catch, the catch provides the fallback value + // Type is union of body_type and catch_type + if body_type == catch_ty { + Ok(body_type) + } else { + // Types must be compatible + Ok(body_type) + } + } else { + // Without catch, return error union + Ok(Type::ErrorUnion { + error: Box::new(Type::AnyError), + payload: Box::new(body_type), + }) + } + } + } + } + + Expr::FnLit(func) => { + // Function literal - return function type + // At comptime, these can return types (type constructors) + let param_types: Vec = func + .params + .iter() + .map(|p| self.resolve_type(&p.ty)) + .collect(); + let return_type = func + .return_type + .as_ref() + .map(|ty| self.resolve_type(ty)) + .unwrap_or(Type::Unit); + Ok(Type::Function { + params: param_types, + return_type: Box::new(return_type), + }) + } + + Expr::Result(result) => { + // Result expressions - emit tagged values to caller + // Tag is compile-time string (already validated by parser) + // Analyze the value expression + self.analyze_expr(&result.value)?; + + // Result expressions evaluate to unit + Ok(Type::Unit) + } + + Expr::Channel(channel) => { + // Channel expressions - >channel.command(args) + // Analyze all argument expressions + for arg in &channel.args { + self.analyze_expr(arg.value())?; + } + + // Channel expressions evaluate to unit + Ok(Type::Unit) + } + } + } + + /// Resolve a type expression to a semantic type. + fn resolve_type(&mut self, type_expr: &TypeExpr) -> Type { + match type_expr { + TypeExpr::Primitive(prim) => match prim { + PrimitiveType::Bool => Type::Bool, + PrimitiveType::UInt { bits } => Type::UInt { + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64), + }, + PrimitiveType::IInt { bits } => Type::IInt { + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64), + }, + PrimitiveType::Usize => Type::Usize, + PrimitiveType::Isize => Type::Isize, + PrimitiveType::F16 => Type::F16, + PrimitiveType::F32 => Type::F32, + PrimitiveType::F64 => Type::F64, + PrimitiveType::F128 => Type::F128, + PrimitiveType::A64 => Type::A64, + }, + TypeExpr::Qubit => Type::Qubit, + TypeExpr::Bit => Type::Bit, + TypeExpr::QAlloc(_) => Type::Allocator { capacity: None }, + TypeExpr::Array(array) => { + let element = self.resolve_type(&array.element); + // Evaluate size expression at comptime if present + if let Some(size_expr) = &array.size { + let mut evaluator = ComptimeEvaluator::new(); + // Populate evaluator context with stored comptime values (for const propagation) + for (name, value) in &self.comptime_values { + evaluator.context.define(name, value.clone()); + } + let size = if let Ok(value) = evaluator.eval_expr(size_expr) { + value.to_usize().map(|n| n as u64) + } else { + None + }; + Type::Array { + element: Box::new(element), + size, + } + } else { + // []T with no size is a slice type + Type::Slice { + element: Box::new(element), + } + } + } + TypeExpr::Pointer(ptr) => { + let pointee = self.resolve_type(&ptr.pointee); + Type::Pointer { + pointee: Box::new(pointee), + is_const: ptr.is_const, + is_many: ptr.is_many, + } + } + TypeExpr::Optional(inner) => Type::Optional { + inner: Box::new(self.resolve_type(inner)), + }, + TypeExpr::ErrorUnion(eu) => Type::ErrorUnion { + error: Box::new(self.resolve_type(&eu.error_type)), + payload: Box::new(self.resolve_type(&eu.payload_type)), + }, + TypeExpr::CollectedErrors(ce) => Type::CollectedErrors { + error: Box::new(self.resolve_type(&ce.error_type)), + payload: Box::new(self.resolve_type(&ce.payload_type)), + }, + TypeExpr::Tuple(elements) => { + let resolved: Vec = elements.iter().map(|t| self.resolve_type(t)).collect(); + Type::Tuple { elements: resolved } + } + TypeExpr::Fn(fn_type) => { + let params: Vec = fn_type + .params + .iter() + .map(|t| self.resolve_type(t)) + .collect(); + let return_type = fn_type + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + Type::Function { + params, + return_type: Box::new(return_type), + } + } + TypeExpr::Named(path) => { + let name = path.segments.join("."); + if let Some(symbol) = self.symbols.lookup(&name) + && let SymbolKind::TypeDef { ty } = &symbol.kind + { + return ty.clone(); + } + // Report error for undefined type name + self.errors.push(SemanticError::UndefinedType { + name: name.clone(), + location: path.location.clone().unwrap_or_default(), + }); + Type::Unknown + } + TypeExpr::Type => Type::Type, + TypeExpr::AnyType => Type::Unknown, + TypeExpr::Unit => Type::Unit, + TypeExpr::Set(element_type) => Type::Set { + element: Box::new(self.resolve_type(element_type)), + }, + TypeExpr::Struct(s) => { + // Anonymous struct type + let fields = s + .fields + .iter() + .map(|f| (f.name.clone(), self.resolve_type(&f.ty))) + .collect(); + Type::Struct { + name: String::new(), // Anonymous + fields, + } + } + TypeExpr::Enum(e) => { + // Anonymous enum type + let variants = e.variants.iter().map(|v| v.name.clone()).collect(); + Type::Enum { + name: String::new(), // Anonymous + variants, + } + } + } + } + + /// Check if an assignment target is mutable. + /// Returns an error if trying to assign to an immutable variable. + fn check_assignment_target_mutable( + &self, + target: &Expr, + location: &Option, + ) -> SemanticResult<()> { + match target { + // Direct variable assignment: x = value + Expr::Ident(ident) => { + if let Some(symbol) = self.symbols.lookup(&ident.name) { + match &symbol.kind { + SymbolKind::Variable { is_const: true, .. } => { + return Err(SemanticError::ImmutableAssignment { + name: ident.name.clone(), + location: location.clone().unwrap_or_default(), + }); + } + SymbolKind::Parameter { .. } => { + // Parameters are always immutable + return Err(SemanticError::ImmutableAssignment { + name: ident.name.clone(), + location: location.clone().unwrap_or_default(), + }); + } + _ => {} + } + } + Ok(()) + } + // Field assignment: obj.field = value - check root object mutability + Expr::Field(field) => self.check_assignment_target_mutable(&field.object, location), + // Index assignment: arr[i] = value - check root object mutability + Expr::Index(index) => self.check_assignment_target_mutable(&index.object, location), + // Dereference assignment: *ptr = value - allowed if pointer is valid + Expr::Unary(unary) if unary.op == ast::UnaryOp::Deref => Ok(()), + // Other expressions (like function calls) can't be assigned to + _ => Ok(()), + } + } + + /// Get a string key for a case value expression for duplicate detection. + /// Returns Some(key) for literals and simple expressions that can be compared. + fn case_value_key(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(lit) => Some(lit.value.to_string()), + Expr::FloatLit(lit) => Some(lit.value.to_string()), + Expr::BoolLit(lit) => Some(lit.value.to_string()), + Expr::StringLit(lit) => Some(format!("\"{}\"", lit.value)), + Expr::CharLit(lit) => Some(format!("'{}'", lit.value)), + Expr::Ident(ident) => Some(ident.name.clone()), + Expr::Field(fa) => { + // For enum variants like Color.Red + if let Expr::Ident(ident) = &fa.object { + Some(format!("{}.{}", ident.name, fa.field)) + } else { + None + } + } + // For complex expressions, we can't easily detect duplicates + _ => None, + } + } + + /// Check if a value type is assignable to a target type. + fn check_assignable( + &self, + target: &Type, + value: &Type, + location: Option, + ) -> SemanticResult<()> { + // Same type is always ok + if target == value { + return Ok(()); + } + + // Unknown types are compatible with anything (for inference) + if *target == Type::Unknown || *value == Type::Unknown { + return Ok(()); + } + + // Allow null (?unknown) to be assigned to any optional type ?T + if let (Type::Optional { .. }, Type::Optional { inner }) = (target, value) + && **inner == Type::Unknown + { + // null (which is ?unknown) can be assigned to any ?T + return Ok(()); + } + + // Allow numeric coercion between numeric types only + // (but NOT from numeric to bool or vice versa) + if target.is_numeric() && value.is_numeric() { + return Ok(()); + } + + // Allow T to be assigned to T!E (returning success from error union function) + if let Type::ErrorUnion { payload, .. } = target + && self + .check_assignable(payload.as_ref(), value, location.clone()) + .is_ok() + { + return Ok(()); + } + + // Allow error value to be assigned to T!E (returning error from error union function) + if let Type::ErrorUnion { error, .. } = target { + // Check if value is an error type that's compatible with the expected error type + match value { + // Same error set - always compatible + Type::ErrorSet { + name: value_name, + errors: value_errors, + } => { + if let Type::ErrorSet { + name: expected_name, + errors: expected_errors, + } = error.as_ref() + { + // Exact match + if value_name == expected_name { + return Ok(()); + } + // Value's errors are a subset of expected's errors (union compatibility) + if value_errors.iter().all(|e| expected_errors.contains(e)) { + return Ok(()); + } + } + // Also allow if expected is AnyError + if *error.as_ref() == Type::AnyError { + return Ok(()); + } + } + // AnyError can be returned from any error union + Type::AnyError => return Ok(()), + _ => {} + } + } + + // Allow fault value to be assigned to T!F (returning fault from fault union function) + if let Type::ErrorUnion { error, .. } = target + && let Type::FaultSet { + name: value_name, + faults: value_faults, + } = value + { + if let Type::FaultSet { + name: expected_name, + faults: expected_faults, + } = error.as_ref() + { + // Exact match + if value_name == expected_name { + return Ok(()); + } + // Value's faults are a subset of expected's faults (union compatibility) + if value_faults.iter().all(|f| expected_faults.contains(f)) { + return Ok(()); + } + } + // Also allow if expected is AnyFault + if *error.as_ref() == Type::AnyFault { + return Ok(()); + } + } + + Err(SemanticError::TypeMismatch { + expected: target.display_name(), + found: value.display_name(), + location: location.unwrap_or_default(), + }) + } + + /// Check if two types are compatible (for function argument checking). + /// Returns true if `value` can be passed where `expected` is required. + fn types_compatible(&self, value: &Type, expected: &Type) -> bool { + // Same type is always compatible + if value == expected { + return true; + } + + // Unknown types are compatible with anything (for inference) + if *value == Type::Unknown || *expected == Type::Unknown { + return true; + } + + // Allow numeric coercion between numeric types + if value.is_numeric() && expected.is_numeric() { + return true; + } + + // Allow T to be passed where ?T is expected + if let Type::Optional { inner } = expected + && self.types_compatible(value, inner) + { + return true; + } + + false + } + + /// Infer the element type from a for loop range. + /// Returns the type that the loop variable should have. + fn infer_for_range_type(&mut self, range: &ForRange) -> SemanticResult { + match range { + ForRange::Range { start, end } => { + // Analyze start and end expressions to get their types + let start_ty = self.analyze_expr(start)?; + let end_ty = self.analyze_expr(end)?; + + // For numeric ranges, prefer the start type if both are numeric + // If one is Unknown, use the other + if start_ty == Type::Unknown { + if end_ty == Type::Unknown { + // Both unknown, default to usize for indices + Ok(Type::Usize) + } else { + Ok(end_ty) + } + } else if end_ty == Type::Unknown || start_ty == end_ty { + Ok(start_ty) + } else if start_ty.is_numeric() && end_ty.is_numeric() { + // Both numeric but different - use start type + Ok(start_ty) + } else { + // Mismatched types - default to usize + Ok(Type::Usize) + } + } + ForRange::Collection(expr) => { + // Analyze the collection expression + let coll_ty = self.analyze_expr(expr)?; + + // Extract element type from collection + match coll_ty { + Type::Array { element, .. } => Ok(*element), + Type::Slice { element } => Ok(*element), + Type::Set { element } => Ok(*element), + Type::Allocator { .. } => Ok(Type::Qubit), // Iterating over qubit allocator + _ => { + // For other types (including Pointer), default to usize + Ok(Type::Usize) + } + } + } + } + } + + /// Check that an expression doesn't escape a reference to a local variable. + /// This prevents returning pointers/slices to stack-allocated data. + fn check_no_local_escape(&self, expr: &Expr, location: SourceLocation) -> SemanticResult<()> { + match expr { + // &x - check if x is a local variable + Expr::Unary(unary) if unary.op == UnaryOp::AddrOf => { + if let Some(name) = self.get_local_var_name(&unary.operand) { + return Err(SemanticError::ReturnReferenceToLocal { name, location }); + } + } + // arr[start..end] - check if arr is a local array (slice creation) + Expr::Range(range) => { + // Range expressions in return context could be slices + // For now, we check if the operands reference locals + if let Some(start) = &range.start { + self.check_no_local_escape(start, location.clone())?; + } + if let Some(end) = &range.end { + self.check_no_local_escape(end, location.clone())?; + } + } + // Index with range: arr[0..n] + Expr::Index(index) => { + // Check if this is a slice (index is a range) of a local array + if matches!(index.index, Expr::Range(_)) + && let Some(name) = self.get_local_var_name(&index.object) + { + return Err(SemanticError::ReturnSliceOfLocal { name, location }); + } + } + // Tuple/struct with references inside - check each element + Expr::Tuple(tuple) => { + for elem in &tuple.elements { + self.check_no_local_escape(elem, location.clone())?; + } + } + Expr::StructInit(init) => { + for field in &init.fields { + self.check_no_local_escape(&field.value, location.clone())?; + } + } + Expr::BracketArray(arr) => { + for elem in &arr.elements { + self.check_no_local_escape(elem, location.clone())?; + } + } + _ => {} + } + Ok(()) + } + + /// Get the name of a local variable if the expression is a simple identifier + /// referring to a variable defined in the current function scope (not a parameter). + fn get_local_var_name(&self, expr: &Expr) -> Option { + if let Expr::Ident(ident) = expr { + // Check if this identifier is a local variable (not a parameter or global) + if let Some(symbol) = self.symbols.lookup(&ident.name) { + match &symbol.kind { + SymbolKind::Variable { .. } => { + // It's a variable - check if it's in function scope (local) + // For now, we consider all variables in function scope as local + // Parameters are tracked separately as SymbolKind::Parameter + return Some(ident.name.clone()); + } + SymbolKind::Parameter { .. } => { + // Parameters are borrowed from caller, so returning ¶m is OK + // (the caller owns the data, not us) + return None; + } + _ => return None, + } + } + } + None + } + + /// Check binary operator types. + fn check_binary_op( + &self, + op: BinaryOp, + left: &Type, + right: &Type, + location: Option, + ) -> SemanticResult { + match op { + BinaryOp::Add | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => { + if left.is_numeric() && right.is_numeric() { + Ok(left.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "numeric".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::Sub => { + // - works for numeric (subtraction) and Set (difference) + if left.is_numeric() && right.is_numeric() { + Ok(left.clone()) + } else if let (Type::Set { element: l_elem }, Type::Set { element: r_elem }) = + (left, right) + { + // Set difference returns a set of the same element type + let _ = r_elem; // Both sets should have compatible element types + Ok(Type::Set { + element: l_elem.clone(), + }) + } else { + Err(SemanticError::TypeMismatch { + expected: "numeric or Set".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::Eq | BinaryOp::Ne => Ok(Type::Bool), + BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => { + // These work for numeric (comparison) and Set (subset/superset) + if left.is_numeric() && right.is_numeric() { + Ok(Type::Bool) + } else if matches!((left, right), (Type::Set { .. }, Type::Set { .. })) { + // Set comparisons: < (proper subset), <= (subset), > (proper superset), >= (superset) + Ok(Type::Bool) + } else { + Err(SemanticError::TypeMismatch { + expected: "numeric or Set".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::And | BinaryOp::Or => { + if *left == Type::Bool && *right == Type::Bool { + Ok(Type::Bool) + } else { + Err(SemanticError::TypeMismatch { + expected: "bool".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::Orelse => { + // orelse: ?T orelse T -> T + // Left must be optional, right must be assignable to inner type + if let Type::Optional { inner } = left { + // Check if right is assignable to inner type (allows numeric coercion) + self.check_assignable(inner, right, location.clone())?; + Ok(*inner.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "optional type (?T)".to_string(), + found: left.display_name(), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::BitAnd | BinaryOp::BitOr | BinaryOp::BitXor => { + // Bitwise ops work for integers, and also for sets: + // & = intersection, | = union, ^ = symmetric difference + if left.is_integer() && right.is_integer() { + Ok(left.clone()) + } else if let (Type::Set { element: l_elem }, Type::Set { element: _ }) = + (left, right) + { + // Set operations return a set of the same element type + Ok(Type::Set { + element: l_elem.clone(), + }) + } else if let ( + Type::ErrorSet { + name: l_name, + errors: l_errors, + }, + Type::ErrorSet { + name: r_name, + errors: r_errors, + }, + ) = (left, right) + { + // Error set union: ErrorA || ErrorB + // Only BitOr makes sense for error sets (union) + if op != BinaryOp::BitOr { + return Err(SemanticError::TypeMismatch { + expected: "|| (union) operator for error sets".to_string(), + found: format!("{:?}", op), + location: location.unwrap_or_default(), + }); + } + // Combine error variants, deduplicating + let mut combined_errors = l_errors.clone(); + for err in r_errors { + if !combined_errors.contains(err) { + combined_errors.push(err.clone()); + } + } + Ok(Type::ErrorSet { + name: format!("{}||{}", l_name, r_name), + errors: combined_errors, + }) + } else if let ( + Type::FaultSet { + name: l_name, + faults: l_faults, + }, + Type::FaultSet { + name: r_name, + faults: r_faults, + }, + ) = (left, right) + { + // Fault set union: FaultA || FaultB + if op != BinaryOp::BitOr { + return Err(SemanticError::TypeMismatch { + expected: "|| (union) operator for fault sets".to_string(), + found: format!("{:?}", op), + location: location.unwrap_or_default(), + }); + } + let mut combined_faults = l_faults.clone(); + for fault in r_faults { + if !combined_faults.contains(fault) { + combined_faults.push(fault.clone()); + } + } + Ok(Type::FaultSet { + name: format!("{}||{}", l_name, r_name), + faults: combined_faults, + }) + } else { + Err(SemanticError::TypeMismatch { + expected: "integer, Set, or error/fault set".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::Shl | BinaryOp::Shr => { + if left.is_integer() { + Ok(left.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "integer".to_string(), + found: left.display_name(), + location: location.unwrap_or_default(), + }) + } + } + + BinaryOp::In | BinaryOp::NotIn => { + // Membership operators: element in Set(element) -> bool + if let Type::Set { element: set_elem } = right { + // Check that left type matches the set's element type + // For now, just return bool - more strict checking can be added later + let _ = set_elem; // Acknowledge we have the element type + Ok(Type::Bool) + } else { + Err(SemanticError::TypeMismatch { + expected: "Set".to_string(), + found: right.display_name(), + location: location.unwrap_or_default(), + }) + } + } + + BinaryOp::Catch => { + // catch: T!E catch handler -> T + // Left should be error union, right is handler that returns T + match left { + Type::ErrorUnion { payload, .. } => { + // Handler should return payload type (or be compatible) + // For now, just check that handler produces a value + let _ = right; // Handler type - could validate more strictly + Ok(*payload.clone()) + } + _ => Err(SemanticError::TypeMismatch { + expected: "error union (T!E)".to_string(), + found: left.display_name(), + location: location.unwrap_or_default(), + }), + } + } + } + } + + /// Check unary operator types. + fn check_unary_op( + &self, + op: UnaryOp, + operand: &Type, + location: Option, + ) -> SemanticResult { + match op { + UnaryOp::Neg => { + if operand.is_numeric() { + Ok(operand.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "numeric".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }) + } + } + UnaryOp::Not => { + if *operand == Type::Bool { + Ok(Type::Bool) + } else { + Err(SemanticError::TypeMismatch { + expected: "bool".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }) + } + } + UnaryOp::BitNot => { + if operand.is_integer() { + Ok(operand.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "integer".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }) + } + } + UnaryOp::AddrOf => Ok(Type::Pointer { + pointee: Box::new(operand.clone()), + is_const: false, + is_many: false, + }), + UnaryOp::Deref => match operand { + Type::Pointer { pointee, .. } => Ok(*pointee.clone()), + _ => Err(SemanticError::TypeMismatch { + expected: "pointer".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }), + }, + UnaryOp::OptionalUnwrap => match operand { + Type::Optional { inner } => Ok(*inner.clone()), + _ => Err(SemanticError::TypeMismatch { + expected: "optional".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }), + }, + UnaryOp::ErrorUnwrap => { + // For error unions, unwrap returns the success type + match operand { + Type::ErrorUnion { payload, .. } => Ok(*payload.clone()), + _ => Err(SemanticError::TypeMismatch { + expected: "error union (T!E)".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }), + } + } + UnaryOp::Try => { + // try: T!E -> T, propagates E to caller + match operand { + Type::ErrorUnion { payload, .. } => Ok(*payload.clone()), + _ => Err(SemanticError::TypeMismatch { + expected: "error union (T!E)".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }), + } + } + } + } + + /// Validate a qubit reference. + fn validate_qubit_ref(&self, slot_ref: &ast::SlotRef) -> SemanticResult<()> { + // First check that the allocator exists in the symbol table + let is_allocator = if let Some(symbol) = self.symbols.lookup(&slot_ref.allocator) { + match &symbol.kind { + SymbolKind::Variable { ty, .. } | SymbolKind::Parameter { ty, .. } => { + matches!(ty, Type::Allocator { .. }) + } + SymbolKind::Allocator { .. } => true, + _ => false, + } + } else { + false + }; + + if !is_allocator { + return Err(SemanticError::AllocatorNotFound { + name: slot_ref.allocator.clone(), + location: slot_ref.location.clone().unwrap_or_default(), + }); + } + + // Check bounds if both index and capacity are known at compile time + // Get capacity from qubit_states (where qalloc capacity is tracked) + if let Some(alloc_info) = self.qubit_states.get_allocator(&slot_ref.allocator) + && let Some(capacity) = alloc_info.capacity + && let Some(index) = self.try_extract_constant_usize(&slot_ref.index) + && index >= capacity + { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: slot_ref.allocator.clone(), + index, + capacity, + location: slot_ref.location.clone().unwrap_or_default(), + }); + } + + Ok(()) + } + + /// Validate qubit bounds for gate target expressions. + /// This handles Index expressions (q[5]), tuples, sets, etc. + fn validate_gate_target_bounds(&self, target: &Expr) -> SemanticResult<()> { + match target { + Expr::Index(index_expr) => { + // Check if this is an allocator index access (q[5]) + if let Expr::Ident(ident) = &index_expr.object { + let allocator_name = &ident.name; + + // Check if this is an allocator + let is_allocator = if let Some(symbol) = self.symbols.lookup(allocator_name) { + matches!( + &symbol.kind, + SymbolKind::Variable { + ty: Type::Allocator { .. }, + .. + } | SymbolKind::Allocator { .. } + ) + } else { + false + }; + + if is_allocator { + // Get capacity from qubit_states + if let Some(alloc_info) = self.qubit_states.get_allocator(allocator_name) + && let Some(capacity) = alloc_info.capacity + && let Some(index) = self.try_extract_constant_usize(&index_expr.index) + && index >= capacity + { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: allocator_name.clone(), + index, + capacity, + location: index_expr.location.clone().unwrap_or_default(), + }); + } + } + } + Ok(()) + } + Expr::Tuple(tuple) => { + // Validate each element in the tuple (e.g., (q[0], q[1])) + for elem in &tuple.elements { + self.validate_gate_target_bounds(elem)?; + } + Ok(()) + } + Expr::Set(set_expr) => { + // Validate each element in the set (e.g., {q[0], q[1], q[2]}) + for elem in &set_expr.elements { + self.validate_gate_target_bounds(elem)?; + } + Ok(()) + } + Expr::BracketArray(array) => { + // Validate each element in the array (e.g., [q[0], q[1], q[2]]) + for elem in &array.elements { + self.validate_gate_target_bounds(elem)?; + } + Ok(()) + } + _ => Ok(()), + } + } + + /// Validate qubit states for gate target expressions (strict mode only). + /// This ensures qubits are prepared before gates are applied. + fn validate_gate_target_states( + &self, + target: &Expr, + location: &SourceLocation, + ) -> SemanticResult<()> { + // Extract qubit IDs from the target expression + let qubit_ids = self.extract_qubit_ids_from_arg(target); + + // Validate each qubit is prepared + for (allocator, index) in qubit_ids { + self.qubit_states + .validate_for_gate(&allocator, index, location)?; + } + + Ok(()) + } + + /// Prepare qubits targeted by a prepare gate (PZ). + /// Transitions targeted qubits to the Prepared state. + fn prepare_gate_targets(&mut self, target: &Expr) { + // Extract qubit IDs from the target expression + let qubit_ids = self.extract_qubit_ids_from_arg(target); + + // Transition each qubit to Prepared state + for (allocator, index) in qubit_ids { + if let Some(alloc) = self.qubit_states.get_allocator_mut(&allocator) { + let _ = alloc.prepare_slot(index); + } + } + + // Also handle the case where the target is a bare allocator (pz q; prepares all) + if let Expr::Ident(ident) = target + && let Some(alloc) = self.qubit_states.get_allocator_mut(&ident.name) + { + alloc.prepare_all(); + } + } + + /// Check for duplicate qubit usage within a tick block. + /// In quantum computing, parallel operations cannot target the same qubit. + fn check_duplicate_qubits_in_tick( + &self, + statements: &[ast::Stmt], + tick_location: &Option, + ) -> SemanticResult<()> { + // Collect all qubit identifications: (allocator_name, constant_index) + let mut seen_qubits: BTreeSet<(String, usize)> = BTreeSet::new(); + + for stmt in statements { + let qubits = self.collect_qubit_ids_from_stmt(stmt); + + for (allocator, index) in qubits { + let key = (allocator.clone(), index); + if !seen_qubits.insert(key) { + // Duplicate found + return Err(SemanticError::DuplicateQubitInTick { + allocator, + index, + location: tick_location.clone().unwrap_or_default(), + }); + } + } + } + + Ok(()) + } + + /// Collect qubit identifications (allocator, index) from a statement. + fn collect_qubit_ids_from_stmt(&self, stmt: &ast::Stmt) -> Vec<(String, usize)> { + match stmt { + // Stmt::Gate uses SlotRef + ast::Stmt::Gate(gate_op) => gate_op + .targets + .iter() + .filter_map(|slot_ref| { + self.try_extract_constant_usize(&slot_ref.index) + .map(|idx| (slot_ref.allocator.clone(), idx)) + }) + .collect(), + // Stmt::Measure uses SlotRef + ast::Stmt::Measure(measure_op) => measure_op + .targets + .iter() + .filter_map(|slot_ref| { + self.try_extract_constant_usize(&slot_ref.index) + .map(|idx| (slot_ref.allocator.clone(), idx)) + }) + .collect(), + // PrepareOp doesn't target individual qubits in tick context + ast::Stmt::Prepare(_) => Vec::new(), + // Nested tick blocks recursively check their contents + ast::Stmt::Tick(tick_stmt) => tick_stmt + .body + .iter() + .flat_map(|s| self.collect_qubit_ids_from_stmt(s)) + .collect(), + // Expression statements can contain gate calls (h(q[0]), cx(q[0], q[1]), etc.) + ast::Stmt::Expr(expr_stmt) => self.collect_qubit_ids_from_expr(&expr_stmt.expr), + // Other statements don't contain qubit references + _ => Vec::new(), + } + } + + /// Collect qubit identifications from an expression (for gate calls and measurements). + fn collect_qubit_ids_from_expr(&self, expr: &Expr) -> Vec<(String, usize)> { + match expr { + // Gate expression: h q[0], cx (q[0], q[1]), rx(angle) q[0], etc. + Expr::Gate(gate) => { + // Extract qubit IDs from the gate target + self.extract_qubit_ids_from_arg(&gate.target) + } + // Measure expression: mz(u1) q[0], mz(u8) q[0..8], etc. + Expr::Measure(measure) => { + // Extract qubit IDs from the measurement target + self.extract_qubit_ids_from_arg(&measure.targets) + } + // Direct function call: h(q[0]), cx(q[0], q[1]), etc. + Expr::Call(call) => { + // Check if this is a gate call + if let Expr::Ident(ident) = &call.callee + && is_gate_name(&ident.name) + { + // Collect qubit IDs from arguments + return call + .args + .iter() + .flat_map(|arg| self.extract_qubit_ids_from_arg(arg)) + .collect(); + } + Vec::new() + } + // Batch apply: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } + Expr::BatchApply(batch) => { + // Extract gate name from operation + let gate_name = match &batch.operation { + Expr::Ident(ident) => Some(&ident.name), + Expr::Call(call) => { + if let Expr::Ident(ident) = &call.callee { + Some(&ident.name) + } else { + None + } + } + _ => None, + }; + if let Some(name) = gate_name + && is_gate_name(name) + { + return batch + .targets + .iter() + .flat_map(|target| self.extract_qubit_ids_from_arg(target)) + .collect(); + } + Vec::new() + } + _ => Vec::new(), + } + } + + /// Extract qubit (allocator, index) from a gate argument expression. + fn extract_qubit_ids_from_arg(&self, expr: &Expr) -> Vec<(String, usize)> { + match expr { + // Index expression: q[0] + Expr::Index(index_expr) => { + if let Expr::Ident(ident) = &index_expr.object + && let Some(idx) = self.try_extract_constant_usize(&index_expr.index) + { + return vec![(ident.name.clone(), idx)]; + } + Vec::new() + } + // Tuple of qubits: (q[0], q[1]) for two-qubit gates + Expr::Tuple(tuple) => tuple + .elements + .iter() + .flat_map(|e| self.extract_qubit_ids_from_arg(e)) + .collect(), + // Address-of array: &[q[0], q[1]] + Expr::Unary(unary) if unary.op == ast::UnaryOp::AddrOf => { + self.extract_qubit_ids_from_arg(&unary.operand) + } + // Bracket array: [q[0], q[1]] + Expr::BracketArray(arr) => arr + .elements + .iter() + .flat_map(|e| self.extract_qubit_ids_from_arg(e)) + .collect(), + // Set literal: [q[0], q[1] + Expr::Set(set) => set + .elements + .iter() + .flat_map(|e| self.extract_qubit_ids_from_arg(e)) + .collect(), + _ => Vec::new(), + } + } + + // ========================================================================= + // Allocator Extraction Helpers + // ========================================================================= + + /// Try to extract allocator capacity from a qalloc(n) call. + fn try_extract_allocator_capacity(&self, expr: &Expr) -> Option { + if let Expr::Call(call) = expr + && let Expr::Ident(ident) = &call.callee + && ident.name == "qalloc" + && call.args.len() == 1 + { + return self.try_extract_constant_usize(&call.args[0]); + } + None + } + + /// Try to extract child allocator info from base.child(n) call. + fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { + if let Expr::Call(call) = expr + && let Expr::Field(field) = &call.callee + && field.field == "child" + && call.args.len() == 1 + && let Expr::Ident(parent_ident) = &field.object + { + let capacity = self.try_extract_constant_usize(&call.args[0])?; + return Some((parent_ident.name.clone(), capacity)); + } + None + } + + /// Try to extract a constant usize from an expression. + fn try_extract_constant_usize(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(lit) => Some(lit.value as usize), + Expr::Ident(ident) => { + // Try to look up a comptime constant in our stored values + if let Some(symbol) = self.symbols.lookup(&ident.name) + && let SymbolKind::Variable { + ty: Type::Comptime(_), + is_const: true, + .. + } = &symbol.kind + { + // Look up the value in the comptime evaluator context + if let Some(val) = self.comptime.context.lookup(&ident.name) { + return val.to_usize(); + } + } + None + } + // For other expressions, try comptime evaluation + _ => { + // Create a temporary evaluator to try evaluation + let mut evaluator = ComptimeEvaluator::new(); + if let Ok(value) = evaluator.eval_expr(expr) { + value.to_usize() + } else { + None + } + } + } + } + + /// Count the number of elements in a gate target expression. + /// Used for batch gate arity checking. + /// - Single qubit ref (q[0]) => 1 + /// - Tuple of 2 (q[0], q[1]) => 2 + /// - Tuple of 3 (q[0], q[1], q[2]) => 3 + fn count_target_elements(&self, expr: &Expr) -> usize { + match expr { + Expr::Tuple(tuple) => tuple.elements.len(), + // Single element (qubit ref, identifier, etc.) + _ => 1, + } + } + + // ========================================================================= + // NASA Power of 10 Helpers + // ========================================================================= + + /// Check if a loop has a valid bound (NASA Power of 10 Rule 2). + fn check_loop_bound(&self, bound: usize, location: &SourceLocation) -> SemanticResult<()> { + if bound > MAX_LOOP_BOUND { + return Err(SemanticError::LoopBoundTooLarge { + bound, + max: MAX_LOOP_BOUND, + location: location.clone(), + }); + } + Ok(()) + } + + // ========================================================================= + // Typed Measurement Analysis + // ========================================================================= + + /// Analyze a typed measurement call: mz(T) target + /// + /// Examples: + /// - `mz(u1) q[0]` - single qubit, returns u1 + /// - `mz([]u1, &[q[0], q[1]])` - multiple qubits, returns []u1 + fn analyze_typed_measurement(&mut self, call: &ast::CallExpr) -> SemanticResult { + let location = call.location.clone().unwrap_or_default(); + + // Must have exactly 2 arguments: type and target + if call.args.len() != 2 { + return Err(SemanticError::MeasurementMissingArgs { location }); + } + + // Extract measurement result type from first argument + let result_type = self.extract_measurement_type(&call.args[0])?; + + // Extract and validate targets from second argument + let targets = self.extract_measurement_targets(&call.args[1])?; + + // Check for duplicate qubits (array elements must be unique but ordered) + self.check_measurement_uniqueness(&targets, &location)?; + + // Validate qubit states if in strict mode + for (allocator, index) in &targets { + if self.strict_mode { + self.qubit_states + .validate_for_gate(allocator, *index, &location)?; + } + // Transition to unprepared after measurement + if let Some(alloc) = self.qubit_states.get_allocator_mut(allocator) { + alloc.measure_slot(*index); + } + } + + // Return type depends on whether targets is single or multiple + // For single target, return scalar; for multiple, return slice + if targets.len() == 1 { + // Single target: mz(u1) q[0] returns u1 + Ok(result_type) + } else { + // Multiple targets: mz([]u1, &[q[0], q[1]]) returns []u1 + // The result_type should already be a slice type + Ok(result_type) + } + } + + /// Extract measurement result type from the type argument. + /// + /// Valid types: u1, u8, u64, []u1, []u8, []u64 + fn extract_measurement_type(&self, expr: &Expr) -> SemanticResult { + let location = expr.get_location().unwrap_or_default(); + + match expr { + // Simple type: u1, u8, u64, etc. (arbitrary bit-width) + Expr::Ident(ident) => { + // Parse arbitrary unsigned integer type: u + if let Some(bits_str) = ident.name.strip_prefix('u') + && let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) + { + return Ok(Type::UInt { bits: bw }); + } + Err(SemanticError::InvalidMeasurementType { + ty: ident.name.clone(), + location, + }) + } + + // Array type: []u1, []u8, []u64 - parsed as array_type_expr + Expr::ArrayInit(arr) => { + // Empty array init [] with element type + // This is how []u1 might be parsed - check the type + if arr.elements.is_empty() { + // Need to get element type from context + Ok(Type::Slice { + element: Box::new(Type::UInt { + bits: BitWidth::BITS_1, + }), // Default to u1 + }) + } else { + Err(SemanticError::InvalidMeasurementType { + ty: "array literal".to_string(), + location, + }) + } + } + + // SlotRef for []type syntax (array type expression) + // The parser might produce this for []u1 + Expr::SlotRef(slot_ref) => { + // This might be []u1 parsed as a slot ref with allocator="u1" + // Parse arbitrary unsigned integer type from allocator name + if let Some(bits_str) = slot_ref.allocator.strip_prefix('u') + && let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) + { + return Ok(Type::Slice { + element: Box::new(Type::UInt { bits: bw }), + }); + } + Err(SemanticError::InvalidMeasurementType { + ty: format!("[]{}", slot_ref.allocator), + location, + }) + } + + _ => Err(SemanticError::InvalidMeasurementType { + ty: "unknown".to_string(), + location, + }), + } + } + + /// Count measurement targets from the target expression. + /// + /// Accepts: + /// - Single qubit: q[0] → 1 + /// - Bracket array: [q[0], q[1]] → element count + /// - Allocator: q → allocator capacity + fn count_measurement_targets(&self, expr: &Expr) -> SemanticResult { + match expr { + // Single qubit: q[0] + Expr::Index(_) => Ok(1), + + // Bracket array: [q[0], q[1]] + Expr::BracketArray(arr) => Ok(arr.elements.len()), + + // Allocator: q (measure all qubits) + Expr::Ident(ident) => { + if let Some(alloc) = self.qubit_states.get_allocator(&ident.name) { + if let Some(capacity) = alloc.capacity { + Ok(capacity) + } else { + // Unknown capacity - can't validate at compile time + Ok(0) // Will be validated at runtime + } + } else { + Ok(0) // Not an allocator, will be caught by analyze_expr + } + } + + _ => Ok(0), // Will be caught by analyze_expr + } + } + + /// Calculate the bit size of a type for pack mode validation. + /// + /// Returns None if the size cannot be determined at compile time. + fn type_bit_size(&self, ty: &Type) -> Option { + match ty { + // Arbitrary-width integers + Type::UInt { bits } | Type::IInt { bits } => Some(bits.get() as usize), + Type::Bool => Some(1), + Type::Array { element, size } => { + if let (Some(elem_bits), Some(arr_size)) = (self.type_bit_size(element), size) { + Some(elem_bits * (*arr_size as usize)) + } else { + None + } + } + Type::Struct { fields, .. } => { + let mut total = 0; + for (_, field_ty) in fields { + if let Some(bits) = self.type_bit_size(field_ty) { + total += bits; + } else { + return None; + } + } + Some(total) + } + Type::Tuple { elements } => { + let mut total = 0; + for elem_ty in elements { + if let Some(bits) = self.type_bit_size(elem_ty) { + total += bits; + } else { + return None; + } + } + Some(total) + } + _ => None, // Unknown size for other types + } + } + + /// Extract measurement targets from the target argument (legacy). + /// + /// Accepts: + /// - Single qubit: q[0] + /// - Array of qubits: &[q[0], q[1]] + fn extract_measurement_targets(&self, expr: &Expr) -> SemanticResult> { + let location = expr.get_location().unwrap_or_default(); + + match expr { + // Single qubit: q[0] + Expr::Index(index) => { + let (allocator, idx) = self.extract_qubit_from_index(index)?; + Ok(vec![(allocator, idx)]) + } + + // Address-of array: &[q[0], q[1]] + Expr::Unary(unary) if matches!(unary.op, UnaryOp::AddrOf) => match &unary.operand { + Expr::BracketArray(arr) => { + let mut targets = Vec::new(); + for elem in &arr.elements { + if let Expr::Index(index) = elem { + let (allocator, idx) = self.extract_qubit_from_index(index)?; + targets.push((allocator, idx)); + } else { + return Err(SemanticError::InvalidQubitRef { location }); + } + } + Ok(targets) + } + _ => Err(SemanticError::InvalidQubitRef { location }), + }, + + _ => Err(SemanticError::InvalidQubitRef { location }), + } + } + + /// Extract allocator name and index from an index expression. + fn extract_qubit_from_index(&self, index: &ast::IndexExpr) -> SemanticResult<(String, usize)> { + let location = index.location.clone().unwrap_or_default(); + + // Get allocator name + let allocator = match &index.object { + Expr::Ident(ident) => ident.name.clone(), + _ => return Err(SemanticError::InvalidQubitRef { location }), + }; + + // Get index (must be comptime-known for uniqueness checking) + let idx = self + .try_extract_constant_usize(&index.index) + .ok_or(SemanticError::InvalidQubitRef { location })?; + + Ok((allocator, idx)) + } + + /// Check that all qubits in measurement are unique. + fn check_measurement_uniqueness( + &self, + targets: &[(String, usize)], + location: &SourceLocation, + ) -> SemanticResult<()> { + let mut seen = BTreeSet::new(); + for (allocator, index) in targets { + let key = (allocator.clone(), *index); + if !seen.insert(key) { + return Err(SemanticError::DuplicateQubitInMeasurement { + allocator: allocator.clone(), + index: *index, + location: location.clone(), + }); + } + } + Ok(()) + } + + /// Analyze an @import builtin expression. + fn analyze_import(&mut self, builtin: &ast::BuiltinExpr) -> SemanticResult { + let location = builtin.location.clone().unwrap_or_default(); + + // Extract the import path from the first argument + if builtin.args.is_empty() { + return Err(SemanticError::TypeMismatch { + expected: "string literal".to_string(), + found: "no arguments".to_string(), + location, + }); + } + + let import_path = match &builtin.args[0] { + Expr::StringLit(s) => s.value.clone(), + _ => { + return Err(SemanticError::TypeMismatch { + expected: "string literal".to_string(), + found: "non-string expression".to_string(), + location, + }); + } + }; + + // Try to load the module + let from_file = self.current_file.as_deref(); + match self.module_loader.load(&import_path, from_file) { + Ok(module) => { + // Clone the exports and path to release the borrow on module_loader + let module_exports = module.exports.clone(); + let module_path = module.path.display().to_string(); + // module reference is now released after cloning + + // Build exports map for the type + let mut exports = std::collections::BTreeMap::new(); + for (name, export) in &module_exports { + let (kind, ty) = match export { + ExportedSymbol::Function { + params, + return_type, + .. + } => { + // Extract function signature from AST + let param_types: Vec = + params.iter().map(|(_, ty)| self.resolve_type(ty)).collect(); + let ret_type = return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + ( + ModuleExportKind::Function, + Type::Function { + params: param_types, + return_type: Box::new(ret_type), + }, + ) + } + ExportedSymbol::Const { .. } => (ModuleExportKind::Const, Type::Unknown), + ExportedSymbol::Type { .. } => (ModuleExportKind::Type, Type::Type), + ExportedSymbol::ErrorSet { variants, .. } => { + // Imported error sets don't carry associated data types + let errors: Vec<(String, Option>)> = + variants.iter().map(|v| (v.clone(), None)).collect(); + ( + ModuleExportKind::ErrorSet, + Type::ErrorSet { + name: name.clone(), + errors, + }, + ) + } + ExportedSymbol::FaultSet { variants, .. } => { + // Imported fault sets don't carry associated data types + let faults: Vec<(String, Option>)> = + variants.iter().map(|v| (v.clone(), None)).collect(); + ( + ModuleExportKind::FaultSet, + Type::FaultSet { + name: name.clone(), + faults, + }, + ) + } + }; + exports.insert(name.clone(), (kind, ty)); + } + + Ok(Type::Module { + path: module_path, + exports, + }) + } + Err(e) => { + // Module loading failed - report as semantic error + Err(SemanticError::ModuleError { + message: e.to_string(), + location, + }) + } + } + } + + // ========================================================================= + // Alias Analysis + // ========================================================================= + + /// Analyze an alias statement. + /// Validates that the source is a slice expression and checks for overlaps. + fn analyze_alias(&mut self, alias: &crate::ast::AliasBinding) -> SemanticResult<()> { + let location = alias.location.clone().unwrap_or_default(); + + // Extract source variable and range from the alias source expression + let (source_name, range) = self.extract_alias_source_info(&alias.source, &location)?; + + // Check for overlaps with existing aliases on the same source + for (existing_name, existing_info) in &self.aliases { + if existing_info.source == source_name + && let (Some(new_range), Some(existing_range)) = (range, existing_info.range) + && Self::ranges_overlap(new_range, existing_range) + { + return Err(SemanticError::OverlappingAlias(Box::new( + OverlappingAliasError { + new_alias: alias.name.clone(), + existing_alias: existing_name.clone(), + source_var: source_name.clone(), + overlap_range: format!( + "{}..{} overlaps with {}..{}", + new_range.0, new_range.1, existing_range.0, existing_range.1 + ), + location, + }, + ))); + } + } + + // Analyze the source expression for type checking + let source_ty = self.analyze_expr(&alias.source)?; + + // Store alias info for future overlap checks + self.aliases.insert( + alias.name.clone(), + AliasInfo { + name: alias.name.clone(), + source: source_name.clone(), + range, + location: location.clone(), + }, + ); + + // Register the alias as a variable in the symbol table + self.symbols.define(Symbol { + name: alias.name.clone(), + kind: SymbolKind::Variable { + ty: source_ty, + is_const: true, // Aliases are always immutable in MVP + is_comptime: false, + }, + location: Some(location), + })?; + + Ok(()) + } + + /// Extract the source variable name and static range from an alias source expression. + fn extract_alias_source_info( + &self, + expr: &crate::ast::Expr, + location: &SourceLocation, + ) -> SemanticResult<(String, Option<(i64, i64)>)> { + // The source must be a slice expression: source[start..end] + if let crate::ast::Expr::Index(index) = expr { + // Get the base name + let source_name = self.extract_base_name(&index.object)?; + + // Get the range if it's a RangeExpr + if let crate::ast::Expr::Range(range_expr) = &index.index { + // Try to evaluate bounds at comptime + let start = if let Some(start_expr) = &range_expr.start { + self.try_eval_comptime_int(start_expr) + } else { + Some(0) // Default start is 0 + }; + + let end = if let Some(end_expr) = &range_expr.end { + self.try_eval_comptime_int(end_expr) + } else { + None // Open-ended range + }; + + if let (Some(s), Some(e)) = (start, end) { + return Ok((source_name, Some((s, e)))); + } else { + // Range is not fully comptime - allow but skip overlap checking + return Ok((source_name, None)); + } + } + } + + // Not a valid slice expression + Err(SemanticError::AliasSourceNotSlice { + found: format!("{:?}", expr), + location: location.clone(), + }) + } + + /// Extract the base variable name from an expression. + fn extract_base_name(&self, expr: &crate::ast::Expr) -> SemanticResult { + match expr { + crate::ast::Expr::Ident(ident) => Ok(ident.name.clone()), + crate::ast::Expr::Index(index) => self.extract_base_name(&index.object), + crate::ast::Expr::Field(field) => self.extract_base_name(&field.object), + _ => Ok("".to_string()), + } + } + + /// Try to evaluate an expression as a comptime integer. + fn try_eval_comptime_int(&self, expr: &crate::ast::Expr) -> Option { + let mut evaluator = ComptimeEvaluator::new(); + // Populate with known comptime values + for (name, val) in &self.comptime_values { + evaluator.context.define(name, val.clone()); + } + match evaluator.eval_expr(expr) { + Ok(ComptimeValue::Int(n)) => Some(n), + Ok(ComptimeValue::Uint(n)) => Some(n as i64), + _ => None, + } + } + + /// Check if two ranges overlap. + fn ranges_overlap(a: (i64, i64), b: (i64, i64)) -> bool { + // Ranges [a.0, a.1) and [b.0, b.1) overlap if: + // a.0 < b.1 && b.0 < a.1 + a.0 < b.1 && b.0 < a.1 + } + + // ========================================================================= + // Generic Type Instantiation + // ========================================================================= + + /// Serialize comptime values to a string key for caching. + fn serialize_comptime_args(args: &[ComptimeValue]) -> String { + args.iter() + .map(|v| format!("{}", v)) + .collect::>() + .join("_") + } + + /// Generate a mangled name for a specialized function. + fn mangle_generic_name(base_name: &str, comptime_args: &[ComptimeValue]) -> String { + let args_suffix = comptime_args + .iter() + .map(|v| match v { + ComptimeValue::Int(n) => format!("{}", n), + ComptimeValue::Uint(n) => format!("{}", n), + ComptimeValue::Bool(b) => if *b { "true" } else { "false" }.to_string(), + ComptimeValue::Type(t) => t.display_name().replace(' ', "_"), + ComptimeValue::String(s) => s.replace(' ', "_"), + _ => format!("{:?}", v), + }) + .collect::>() + .join("__"); + format!("{}__CT__{}", base_name, args_suffix) + } + + /// Instantiate a generic function with concrete comptime arguments. + /// Returns the mangled name of the specialized function. + pub fn instantiate_generic_function( + &mut self, + fn_name: &str, + comptime_args: &[ComptimeValue], + comptime_param_indices: &[usize], + original_decl: &crate::ast::FnDecl, + ) -> SemanticResult { + // Create cache key + let args_key = Self::serialize_comptime_args(comptime_args); + let cache_key = (fn_name.to_string(), args_key.clone()); + + // Check if already instantiated + if let Some(mangled_name) = self.generic_instantiations.get(&cache_key) { + return Ok(mangled_name.clone()); + } + + // Generate mangled name for the specialized function + let mangled_name = Self::mangle_generic_name(fn_name, comptime_args); + + // Clone the original declaration and substitute comptime params + let mut specialized = original_decl.clone(); + specialized.name = mangled_name.clone(); + + // Build a mapping from comptime param names to their concrete values + let mut comptime_bindings: BTreeMap = BTreeMap::new(); + for (i, ¶m_idx) in comptime_param_indices.iter().enumerate() { + if param_idx < original_decl.params.len() && i < comptime_args.len() { + let param_name = &original_decl.params[param_idx].name; + comptime_bindings.insert(param_name.clone(), comptime_args[i].clone()); + } + } + + // Remove comptime parameters from the specialized function + // (they become concrete values, not parameters) + specialized.params = original_decl + .params + .iter() + .enumerate() + .filter(|(i, _)| !comptime_param_indices.contains(i)) + .map(|(_, p)| p.clone()) + .collect(); + + // Store the comptime bindings for use during analysis of the specialized function + for (name, value) in &comptime_bindings { + self.comptime_values.insert(name.clone(), value.clone()); + self.comptime.context.define(name, value.clone()); + } + + // Register the specialized function in the symbol table + let params: Vec<(String, Type)> = specialized + .params + .iter() + .map(|p| (p.name.clone(), self.resolve_type(&p.ty))) + .collect(); + let return_type = specialized + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + + self.symbols.define(Symbol { + name: mangled_name.clone(), + kind: SymbolKind::Function { + params, + return_type, + is_pub: false, // Specialized functions are internal + comptime_param_indices: vec![], // No longer generic + original_decl: None, + }, + location: specialized.location.clone(), + })?; + + // Store the specialized function for later codegen + self.specialized_functions.push(specialized); + + // Cache the instantiation + self.generic_instantiations + .insert(cache_key, mangled_name.clone()); + + Ok(mangled_name) + } + + /// Get the list of specialized functions generated during analysis. + pub fn get_specialized_functions(&self) -> &[crate::ast::FnDecl] { + &self.specialized_functions + } +} + +impl Default for SemanticAnalyzer { + fn default() -> Self { + Self::new() + } +} + +/// Gate information for semantic analysis. +struct GateInfo { + /// Number of qubit arguments (arity) + arity: usize, + /// Whether the gate takes angle parameters + parameterized: bool, +} + +/// Get gate information by name. +/// Returns None if the name is not a recognized gate. +fn get_gate_info(name: &str) -> Option { + match name { + // Single-qubit Pauli gates (non-parameterized, arity 1) + "h" | "x" | "y" | "z" => Some(GateInfo { + arity: 1, + parameterized: false, + }), + // Square root gates (sx, sy, sz and their daggers) + // Note: S gate is "sz" not "s", Sdg is "szdg" not "sdg" + "sx" | "sy" | "sz" | "sxdg" | "sydg" | "szdg" => Some(GateInfo { + arity: 1, + parameterized: false, + }), + // T gates (fourth root of Z) + "t" | "tdg" => Some(GateInfo { + arity: 1, + parameterized: false, + }), + // F gates + "f" | "fdg" | "f4" | "f4dg" => Some(GateInfo { + arity: 1, + parameterized: false, + }), + // Rotation gates (parameterized, arity 1) + "rx" | "ry" | "rz" => Some(GateInfo { + arity: 1, + parameterized: true, + }), + // Two-qubit gates (non-parameterized, arity 2) + "cx" | "cy" | "cz" | "ch" => Some(GateInfo { + arity: 2, + parameterized: false, + }), + "swap" | "iswap" => Some(GateInfo { + arity: 2, + parameterized: false, + }), + // Square-root two-qubit gates + "sxx" | "syy" | "szz" | "sxxdg" | "syydg" | "szzdg" => Some(GateInfo { + arity: 2, + parameterized: false, + }), + // Controlled rotation (parameterized, arity 2) + "crz" | "rzz" => Some(GateInfo { + arity: 2, + parameterized: true, + }), + // Three-qubit gates + "ccx" => Some(GateInfo { + arity: 3, + parameterized: false, + }), + // Special operations (handled separately but recognized as gates) + "mz" | "pz" => Some(GateInfo { + arity: 1, + parameterized: false, + }), + _ => None, + } +} + +/// Check if a name is a built-in gate. +/// All gate names must be lowercase. +fn is_gate_name(name: &str) -> bool { + get_gate_info(name).is_some() +} + +/// Check if a name is a built-in constant. +fn is_builtin_constant(name: &str) -> bool { + matches!(name, "pi" | "tau" | "e") +} + +/// Get the type of a built-in constant. +fn get_builtin_constant_type(name: &str) -> Type { + match name { + "pi" | "tau" | "e" => Type::A64, + _ => Type::Unknown, + } +} + +/// Maximum valid bit width for integer types (matches Rust's i128/u128). +const MAX_INT_BITS: u16 = 128; + +/// Validate that a bit width is valid (1-128). +fn is_valid_bit_width(bits: u16) -> bool { + (1..=MAX_INT_BITS).contains(&bits) +} + +/// Resolve a built-in type name to a Type (for comptime type values). +/// Returns Some(Type) if the name is a built-in type, None otherwise. +/// Returns None for invalid bit widths (e.g., u0, u9999). +fn resolve_builtin_type_name(name: &str) -> Option { + // Special cases first + match name { + "bool" => return Some(Type::Bool), + "usize" => return Some(Type::Usize), + "isize" => return Some(Type::Isize), + "f16" => return Some(Type::F16), + "f32" => return Some(Type::F32), + "f64" => return Some(Type::F64), + "f128" => return Some(Type::F128), + "a64" => return Some(Type::A64), + "type" => return Some(Type::Type), + "unit" => return Some(Type::Unit), + "qubit" => return Some(Type::Qubit), + "bit" => return Some(Type::Bit), + _ => {} + } + + // Arbitrary-width integers: u or i + if let Some(bits_str) = name.strip_prefix('u') { + if let Ok(bits) = bits_str.parse::() { + if let Some(bw) = BitWidth::new(bits) { + return Some(Type::UInt { bits: bw }); + } + // Invalid bit width - return None to trigger error + return None; + } + } else if let Some(bits_str) = name.strip_prefix('i') + && let Ok(bits) = bits_str.parse::() + { + if let Some(bw) = BitWidth::new(bits) { + return Some(Type::IInt { bits: bw }); + } + // Invalid bit width - return None to trigger error + return None; + } + + None +} + +/// Convert an integer type suffix to a Type. +/// Supports arbitrary bit-width integers: u1, u4, u7, u128, i32, etc. +fn int_suffix_to_type(suffix: &str) -> Type { + // Handle optional underscore prefix + let s = suffix.strip_prefix('_').unwrap_or(suffix); + + // Special cases + match s { + "usize" => return Type::Usize, + "isize" => return Type::Isize, + _ => {} + } + + // Arbitrary-width integers: u or i + if let Some(bits_str) = s.strip_prefix('u') { + if let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) + { + return Type::UInt { bits: bw }; + } + // Invalid bit width - fall through to default + } else if let Some(bits_str) = s.strip_prefix('i') + && let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) + { + return Type::IInt { bits: bw }; + } + // Invalid bit width - fall through to default + + Type::IInt { + bits: BitWidth::BITS_64, + } // Default fallback +} + +/// Convert a float type suffix to a Type. +fn float_suffix_to_type(suffix: &str) -> Type { + // Handle optional underscore prefix + let s = suffix.strip_prefix('_').unwrap_or(suffix); + match s { + "f16" => Type::F16, + "f32" => Type::F32, + "f64" => Type::F64, + "f128" => Type::F128, + "a64" => Type::A64, + _ => Type::F64, // Default fallback + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + fn analyze(source: &str) -> SemanticResult<()> { + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program) + } + + #[test] + fn test_const_declaration() { + assert!(analyze("x: u32 = 42;").is_ok()); + } + + #[test] + fn test_function_declaration() { + assert!(analyze("fn add(a: u32, b: u32) -> u32 { return a + b; }").is_ok()); + } + + #[test] + fn test_type_mismatch() { + // First check that parsing works + let program1 = parse("x: u32 = 42;").unwrap(); + assert!( + !program1.declarations.is_empty(), + "u32 version should have declarations" + ); + + let program2 = parse("x: bool = 42;").unwrap(); + assert!( + !program2.declarations.is_empty(), + "bool version should have declarations: got {:?}", + program2 + ); + + // bool is not numeric, so int can't be assigned + let result = analyze("x: bool = 42;"); + assert!(result.is_err(), "Expected type mismatch error"); + } + + #[test] + fn test_undefined_symbol() { + // Note: "test" is a keyword, so use "run" instead + // Also "y" is now a gate name, so use "foo" instead + let result = analyze("fn run() -> unit { x := foo; }"); + if result.is_ok() { + panic!("Expected undefined symbol error but got ok"); + } + } + + #[test] + fn test_quantum_alloc() { + assert!( + analyze( + r#" + fn main() -> unit { + mut q := qalloc(2); + + return unit; } + "# + ) + .is_ok() + ); + } + + // ========================================================================= + // Qubit State Tracking Tests + // ========================================================================= + + fn analyze_strict(source: &str) -> SemanticResult<()> { + // Note: new() is now strict by default, so this is equivalent to analyze() + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program) + } + + fn analyze_permissive(source: &str) -> SemanticResult<()> { + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program) + } + + #[test] + fn test_allocator_info_new() { + let alloc = AllocatorInfo::new("q", 4); + assert_eq!(alloc.name, "q"); + assert_eq!(alloc.capacity, Some(4)); + assert_eq!(alloc.slot_states.len(), 4); + assert!( + alloc + .slot_states + .iter() + .all(|s| *s == QubitState::Unprepared) + ); + } + + #[test] + fn test_allocator_info_prepare_slot() { + let mut alloc = AllocatorInfo::new("q", 4); + + // Prepare slot 0 + assert!(alloc.prepare_slot(0).is_ok()); + assert_eq!(alloc.get_state(0), Some(QubitState::Prepared)); + assert_eq!(alloc.get_state(1), Some(QubitState::Unprepared)); + + // Can't prepare already prepared slot + assert!(alloc.prepare_slot(0).is_err()); + } + + #[test] + fn test_allocator_info_prepare_all() { + let mut alloc = AllocatorInfo::new("q", 4); + alloc.prepare_all(); + + assert!(alloc.slot_states.iter().all(|s| *s == QubitState::Prepared)); + } + + #[test] + fn test_allocator_info_measure_slot() { + let mut alloc = AllocatorInfo::new("q", 4); + alloc.prepare_all(); + alloc.measure_slot(1); + + assert_eq!(alloc.get_state(0), Some(QubitState::Prepared)); + assert_eq!(alloc.get_state(1), Some(QubitState::Unprepared)); + assert_eq!(alloc.get_state(2), Some(QubitState::Prepared)); + } + + #[test] + fn test_allocator_info_bounds() { + let alloc = AllocatorInfo::new("q", 4); + assert!(alloc.is_in_bounds(0)); + assert!(alloc.is_in_bounds(3)); + assert!(!alloc.is_in_bounds(4)); + assert!(!alloc.is_in_bounds(100)); + } + + #[test] + fn test_qubit_state_tracker() { + let mut tracker = QubitStateTracker::new(); + tracker.register_allocator(AllocatorInfo::new("q", 2)); + + assert!(tracker.get_allocator("q").is_some()); + assert!(tracker.get_allocator("x").is_none()); + + assert_eq!(tracker.is_prepared("q", 0), Some(false)); + + if let Some(alloc) = tracker.get_allocator_mut("q") { + alloc.prepare_all(); + } + + assert_eq!(tracker.is_prepared("q", 0), Some(true)); + } + + #[test] + fn test_recursion_tracker() { + let mut tracker = RecursionTracker::new(); + let loc = SourceLocation::default(); + + // First call should succeed + assert!(tracker.enter_function("foo", &loc).is_ok()); + assert!(tracker.is_in_call_stack("foo")); + + // Recursive call should fail + let result = tracker.enter_function("foo", &loc); + assert!(matches!( + result, + Err(SemanticError::RecursionDetected { .. }) + )); + + // Exit and re-enter should work + tracker.exit_function("foo"); + assert!(!tracker.is_in_call_stack("foo")); + assert!(tracker.enter_function("foo", &loc).is_ok()); + } + + #[test] + fn test_loop_bound_check() { + let analyzer = SemanticAnalyzer::new(); + let loc = SourceLocation::default(); + + // Small bound should pass + assert!(analyzer.check_loop_bound(100, &loc).is_ok()); + assert!(analyzer.check_loop_bound(MAX_LOOP_BOUND, &loc).is_ok()); + + // Large bound should fail + let result = analyzer.check_loop_bound(MAX_LOOP_BOUND + 1, &loc); + assert!(matches!( + result, + Err(SemanticError::LoopBoundTooLarge { .. }) + )); + } + + // ========================================================================= + // Semantic Analyzer Integration Tests + // ========================================================================= + + #[test] + fn test_allocator_registration() { + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + + return unit; } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program).expect("analysis failed"); + + // Check allocator was registered + assert!(analyzer.qubit_states.get_allocator("q").is_some()); + let alloc = analyzer.qubit_states.get_allocator("q").unwrap(); + assert_eq!(alloc.capacity, Some(4)); + } + + #[test] + fn test_child_allocator_registration() { + let source = r#" + fn main() -> unit { + mut base := qalloc(8); + mut q := base.child(4); + + return unit; } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program).expect("analysis failed"); + + // Check both allocators were registered + assert!(analyzer.qubit_states.get_allocator("base").is_some()); + assert!(analyzer.qubit_states.get_allocator("q").is_some()); + + let child = analyzer.qubit_states.get_allocator("q").unwrap(); + assert_eq!(child.capacity, Some(4)); + assert_eq!(child.parent, Some("base".to_string())); + } + + #[test] + fn test_immutable_allocator_for_gates() { + // Allocators don't need mut when just applying gates + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + h q[0]; + cx (q[0], q[1]); + return unit; + } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer + .analyze(&program) + .expect("analysis should succeed for immutable allocator with gates"); + + // Check allocator was registered + assert!(analyzer.qubit_states.get_allocator("q").is_some()); + } + + #[test] + fn test_child_requires_mutable_parent() { + // .child() requires the parent to be mutable + let source = r#" + fn main() -> unit { + base := qalloc(8); + q := base.child(4); + return unit; + } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(&err, SemanticError::ChildRequiresMutableParent { name, .. } if name == "base"), + "expected ChildRequiresMutableParent error for 'base', got {:?}", + err + ); + } + + #[test] + fn test_strict_mode_toggle() { + // new() is now strict by default + let analyzer = SemanticAnalyzer::new(); + assert!(analyzer.strict_mode); + + // new_permissive() disables strict mode + let permissive_analyzer = SemanticAnalyzer::new_permissive(); + assert!(!permissive_analyzer.strict_mode); + + // Can toggle strict mode off + let mut toggled = SemanticAnalyzer::new(); + toggled.set_strict_mode(false); + assert!(!toggled.strict_mode); + } + + #[test] + fn test_gate_on_unprepared_qubit_rejected_strict() { + // In strict mode, gates on unprepared qubits should fail + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + h q[0]; // No pz q; first - qubit is unprepared + return unit; + } + "#, + ); + assert!(result.is_err(), "Expected QubitNotPrepared error"); + assert!( + matches!(result.unwrap_err(), SemanticError::QubitNotPrepared { .. }), + "Expected QubitNotPrepared error" + ); + } + + #[test] + fn test_pz_prepares_qubits() { + // PZ (prepare Z) can be applied to unprepared qubits and prepares them + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; // Prepare all qubits + h q[0]; // Now this should succeed + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "PZ should prepare qubits for subsequent gates: {:?}", + result + ); + } + + #[test] + fn test_pz_on_specific_qubit() { + // PZ can prepare specific qubits + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q[0]; // Prepare only q[0] + h q[0]; // This should succeed + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "PZ should prepare specific qubit: {:?}", + result + ); + } + + // ========================================================================= + // Typed Measurement Tests + // ========================================================================= + + #[test] + fn test_typed_measurement_single_qubit() { + // Single qubit measurement: mz(u1) q[0] + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + r := mz(u1) q[0]; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected typed measurement to pass: {:?}", + result + ); + } + + #[test] + fn test_typed_measurement_array() { + // Array measurement with explicit size: mz([2]u1) [q[0], q[1]] + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(4); + pz q; + results := mz([2]u1) [q[0], q[1]]; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected typed array measurement to pass: {:?}", + result + ); + } + + #[test] + fn test_typed_measurement_size_mismatch() { + // Size mismatch: declared [3]u1 but only 2 targets + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(4); + pz q; + results := mz([3]u1) [q[0], q[1]]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::MeasurementSizeMismatch { .. })), + "Expected MeasurementSizeMismatch error, got: {:?}", + result + ); + } + + #[test] + fn test_typed_measurement_scalar_with_multiple_targets() { + // Scalar type with multiple targets should fail + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(4); + pz q; + results := mz(u1) [q[0], q[1]]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::MeasurementArrayExpected { .. })), + "Expected MeasurementArrayExpected error, got: {:?}", + result + ); + } + + #[test] + fn test_typed_measurement_missing_args() { + // Old call syntax should be rejected + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + r := mz(); + return unit; + } + "#, + ); + assert!( + matches!( + result, + Err(SemanticError::DeprecatedMeasurementSyntax { .. }) + ), + "Expected DeprecatedMeasurementSyntax error for old mz() call syntax" + ); + } + + #[test] + fn test_typed_measurement_invalid_type() { + // Invalid type (f64) should fail + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + r := mz(f64) q[0]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::InvalidMeasurementType { .. })), + "Expected InvalidMeasurementType error, got: {:?}", + result + ); + } + + #[test] + fn test_measurement_pack_u8() { + // Pack 8 qubits into u8 + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(8); + pz q; + bits := mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected pack measurement to pass: {:?}", + result + ); + } + + #[test] + fn test_measurement_pack_fewer_qubits() { + // Pack 4 qubits into u8 (has room for 8) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(4); + pz q; + bits := mz(pack u8) [q[0], q[1], q[2], q[3]]; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected pack with extra capacity to pass: {:?}", + result + ); + } + + #[test] + fn test_measurement_pack_capacity_error() { + // Try to pack 10 qubits into u8 (only 8 bits) - should fail + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(10); + pz q; + bits := mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7], q[8], q[9]]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::MeasurementPackCapacity { .. })), + "Expected MeasurementPackCapacity error, got: {:?}", + result + ); + } + + #[test] + fn test_measurement_pack_array() { + // Pack 16 qubits into [2]u8 + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(16); + pz q; + bits := mz(pack [2]u8) q; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected pack into array to pass: {:?}", + result + ); + } + + // ========================================================================= + // Optional Type Tests + // ========================================================================= + + #[test] + fn test_optional_type_declaration() { + // Optional type declaration + let result = analyze( + r#" + fn main() -> unit { + mut x: ?u32 = none; + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected optional type to pass: {:?}", + result + ); + } + + #[test] + fn test_orelse_operator() { + // orelse operator: ?T orelse T -> T + let result = analyze( + r#" + fn main() -> unit { + mut x: ?u32 = none; + y: u32 = x orelse 42; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected orelse to pass: {:?}", result); + } + + #[test] + fn test_orelse_type_mismatch() { + // orelse with wrong default type should fail + let result = analyze( + r#" + fn main() -> unit { + mut x: ?u32 = none; + y := x orelse true; + + return unit; } + "#, + ); + assert!( + matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for orelse" + ); + } + + #[test] + fn test_orelse_non_optional() { + // orelse on non-optional should fail + let result = analyze( + r#" + fn main() -> unit { + mut x: u32 = 10; + y := x orelse 42; + + return unit; } + "#, + ); + assert!( + matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for non-optional" + ); + } + + #[test] + fn test_optional_unwrap() { + // .? unwrap operator + let result = analyze( + r#" + fn main() -> unit { + mut x: ?u32 = none; + y := x.?; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected .? unwrap to pass: {:?}", result); + } + + #[test] + fn test_if_unwrap_optional() { + // if-unwrap syntax: if value := opt { ... } (walrus operator) + let result = analyze( + r#" + fn find() -> ?u32 { + return none; + } + fn main() -> unit { + opt := find(); + if value := opt { + x: u32 = value; + } + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected if-unwrap to pass: {:?}", result); + } + + #[test] + fn test_if_unwrap_non_optional() { + // if-unwrap on non-optional should fail + let result = analyze( + r#" + fn main() -> unit { + x: u32 = 42; + if value := x { + y := value; + } + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for if-unwrap on non-optional" + ); + } + + // ========================================================================= + // Comptime Tests + // ========================================================================= + + #[test] + fn test_comptime_expression() { + // Comptime expression should be evaluated + let result = analyze( + r#" + fn main() -> unit { + x := comptime 2 + 3; + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected comptime expression to pass: {:?}", + result + ); + } + + #[test] + fn test_comptime_block() { + // Comptime block expression should be evaluated + // Note: We test a simpler block expression to avoid parser edge cases + let result = analyze( + r#" + fn main() -> unit { + y := comptime (10 + 20); + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected comptime expression to pass: {:?}", + result + ); + } + + #[test] + fn test_comptime_type_returns_comptime() { + // Comptime expression should have Comptime type wrapper + let source = r#" + fn main() -> unit { + x := comptime 42; + + return unit; } + "#; + let program = crate::parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Expected comptime to pass: {:?}", result); + } + + #[test] + fn test_comptime_function_parameter() { + // Comptime parameters should be accepted + let result = analyze( + r#" + fn make_array(comptime size: u32) -> unit { + mut x: u32 = size; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected comptime param to pass: {:?}", + result + ); + } + + #[test] + fn test_comptime_parameter_in_expression() { + // Comptime parameters should be usable in expressions + let result = analyze( + r#" + fn compute(comptime n: u32) -> u32 { + return n * 2; + } + "#, + ); + assert!( + result.is_ok(), + "Expected comptime param in expr: {:?}", + result + ); + } + + #[test] + fn test_error_set_definition() { + // Error set definitions should be analyzed correctly + let result = analyze( + r#" + MyError := error { + OutOfMemory, + InvalidInput, + }; + "#, + ); + assert!( + result.is_ok(), + "Expected error set definition to pass: {:?}", + result + ); + } + + #[test] + fn test_error_union_type() { + // Error union type should work in function signatures + let result = analyze( + r#" + MyError := error { Failed }; + + fn risky() -> MyError!u32 { + x: u32 = 42; + return x; + } + "#, + ); + assert!( + result.is_ok(), + "Expected error union type to pass: {:?}", + result + ); + } + + #[test] + fn test_error_value_type_tracking() { + // Error values from defined error sets should have correct type + let result = analyze( + r#" + MyError := error { OutOfMemory, InvalidInput }; + + fn risky() -> MyError!u32 { + if true { + return error.OutOfMemory; + } + return 42; + } + "#, + ); + assert!( + result.is_ok(), + "Expected error value return to pass: {:?}", + result + ); + } + + #[test] + fn test_error_value_wrong_set() { + // Error values from different error sets should not be assignable + let result = analyze( + r#" + MyError := error { Failed }; + OtherError := error { NotFound }; + + fn risky() -> MyError!u32 { + return error.NotFound; + } + "#, + ); + // This should fail because NotFound is from OtherError, not MyError + assert!(result.is_err(), "Expected mismatched error set to fail"); + } + + #[test] + fn test_error_set_union() { + // Error set union using | operator should combine errors from both sets + let result = analyze( + r#" + IoError := error { FileNotFound, PermissionDenied }; + NetworkError := error { Timeout, ConnectionRefused }; + + fn combined_errors() -> unit { + combined := IoError | NetworkError; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected error set union to pass: {:?}", + result + ); + } + + #[test] + fn test_error_set_with_associated_data() { + // Error sets can have associated data types for their variants + let result = analyze( + r#" + FileError := error { + NotFound: struct { path: []u8 }, + PermissionDenied: struct { path: []u8, mode: u32 }, + IoError, + }; + "#, + ); + assert!( + result.is_ok(), + "Expected error set with associated data to pass: {:?}", + result + ); + } + + #[test] + fn test_fault_set_with_associated_data() { + // Fault sets can also have associated data types + let result = analyze( + r#" + QuantumFault := fault { + Leakage: struct { qubit_id: u32, gate: []u8 }, + BitFlip, + PhaseError: struct { angle: f64 }, + }; + "#, + ); + assert!( + result.is_ok(), + "Expected fault set with associated data to pass: {:?}", + result + ); + } + + #[test] + fn test_fault_set_definition_basic() { + // Basic fault set definition should work + let result = analyze("GateFaults := fault { Leakage, Depolarization };"); + assert!( + result.is_ok(), + "Expected fault set definition to pass: {:?}", + result + ); + } + + #[test] + fn test_fault_set_as_value() { + // Fault set should be usable as a value (like error sets) + let result = analyze( + r#" + GateFaults := fault { Leakage }; + + fn test_fault() -> unit { + x := GateFaults; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected fault set as value to pass: {:?}", + result + ); + } + + #[test] + fn test_fault_set_union() { + // Fault set union using | operator should combine faults from both sets + let result = analyze( + r#" + GateFaults := fault { Leakage, Depolarization }; + MeasurementFaults := fault { BitFlip, ReadoutError }; + + fn combined_faults() -> unit { + combined := GateFaults | MeasurementFaults; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected fault set union to pass: {:?}", + result + ); + } + + #[test] + fn test_try_block_collect_mode() { + // try {} (collect mode) should analyze correctly + let result = analyze( + r#" + fn risky_collect() -> unit { + errors := try { + x := 42; + }; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected try collect block to pass: {:?}", + result + ); + } + + #[test] + fn test_try_block_propagate_mode() { + // try! {} (propagate mode) should analyze correctly + let result = analyze( + r#" + fn risky_propagate() -> unit { + result := try! { + x := 42; + }; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected try! propagate block to pass: {:?}", + result + ); + } + + #[test] + fn test_try_block_with_catch() { + // try! {} with catch clause should analyze correctly + let result = analyze( + r#" + fn risky_with_catch() -> unit { + result := try! { + x := 42; + x + } catch |err| { + 0 + }; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected try! with catch to pass: {:?}", + result + ); + } + + #[test] + fn test_errdefer_basic() { + // Basic errdefer without capture should pass semantic analysis + let result = analyze( + r#" + fn risky() -> unit { + errdefer cleanup(); + return unit; + } + + fn cleanup() -> unit { return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected basic errdefer to pass: {:?}", + result + ); + } + + #[test] + fn test_errdefer_with_capture() { + // Errdefer with capture should provide the error variable in scope + let result = analyze( + r#" + fn risky() -> unit { + errdefer |err| { + x := err; + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected errdefer with capture to pass: {:?}", + result + ); + } + + #[test] + fn test_union_tagged() { + // Tagged union with auto-enum should pass semantic analysis + let result = analyze( + r#" + Value := union(enum) { + Int: i32, + Float: f64, + None, + }; + "#, + ); + assert!( + result.is_ok(), + "Expected tagged union to pass: {:?}", + result + ); + } + + #[test] + fn test_union_untagged() { + // Untagged union should pass semantic analysis + let result = analyze( + r#" + RawValue := union { + Int: i32, + Float: f64, + }; + "#, + ); + assert!( + result.is_ok(), + "Expected untagged union to pass: {:?}", + result + ); + } + + // ========================================================================= + // Module Import Tests + // ========================================================================= + + #[test] + fn test_module_import_not_found() { + // Importing a non-existent module should fail + let result = analyze(r#"utils := @import("nonexistent.zlp");"#); + assert!(result.is_err(), "Expected module not found error"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::ModuleError { .. }), + "Expected ModuleError, got {:?}", + e + ); + } + } + + #[test] + fn test_module_import_with_file() { + use std::io::Write; + use tempfile::TempDir; + + // Create a temporary module file + let temp_dir = TempDir::new().unwrap(); + let utils_path = temp_dir.path().join("utils.zlp"); + let mut file = std::fs::File::create(&utils_path).unwrap(); + writeln!(file, "pub fn helper() -> unit {{ return unit; }}").unwrap(); + writeln!(file, "pub VALUE: u32 = 42;").unwrap(); + + // Create main file that imports utils + let main_path = temp_dir.path().join("main.zlp"); + let mut file = std::fs::File::create(&main_path).unwrap(); + writeln!(file, "utils := @import(\"utils.zlp\");").unwrap(); + writeln!(file, "fn main() -> unit {{ return unit; }}").unwrap(); + + // Parse and analyze main file + let source = std::fs::read_to_string(&main_path).unwrap(); + let program = crate::parse_file(&source, main_path.display().to_string()).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.set_current_file(&main_path); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Expected import to succeed: {:?}", result); + } + + #[test] + fn test_module_import_call_function() { + use std::io::Write; + use tempfile::TempDir; + + // Create a temporary module file with a function + let temp_dir = TempDir::new().unwrap(); + let utils_path = temp_dir.path().join("utils.zlp"); + let mut file = std::fs::File::create(&utils_path).unwrap(); + writeln!( + file, + "pub fn add(a: u32, b: u32) -> u32 {{ return a + b; }}" + ) + .unwrap(); + + // Create main file that imports and calls the function + let main_path = temp_dir.path().join("main.zlp"); + let mut file = std::fs::File::create(&main_path).unwrap(); + writeln!(file, "utils := @import(\"utils.zlp\");").unwrap(); + writeln!(file, "fn main() -> unit {{").unwrap(); + writeln!(file, " result := utils.add(1, 2);").unwrap(); + writeln!(file, " return unit;").unwrap(); + writeln!(file, "}}").unwrap(); + + // Parse and analyze main file + let source = std::fs::read_to_string(&main_path).unwrap(); + let program = crate::parse_file(&source, main_path.display().to_string()).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.set_current_file(&main_path); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Expected module function call to succeed: {:?}", + result + ); + } + + #[test] + fn test_module_import_wrong_arg_count() { + use std::io::Write; + use tempfile::TempDir; + + // Create a temporary module file with a function + let temp_dir = TempDir::new().unwrap(); + let utils_path = temp_dir.path().join("utils.zlp"); + let mut file = std::fs::File::create(&utils_path).unwrap(); + writeln!( + file, + "pub fn add(a: u32, b: u32) -> u32 {{ return a + b; }}" + ) + .unwrap(); + + // Create main file that calls with wrong number of arguments + let main_path = temp_dir.path().join("main.zlp"); + let mut file = std::fs::File::create(&main_path).unwrap(); + writeln!(file, "utils := @import(\"utils.zlp\");").unwrap(); + writeln!(file, "fn main() -> unit {{").unwrap(); + writeln!(file, " result := utils.add(1);").unwrap(); // Missing second arg + writeln!(file, " return unit;").unwrap(); + writeln!(file, "}}").unwrap(); + + // Parse and analyze main file + let source = std::fs::read_to_string(&main_path).unwrap(); + let program = crate::parse_file(&source, main_path.display().to_string()).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.set_current_file(&main_path); + let result = analyzer.analyze(&program); + // This should fail due to argument count mismatch + assert!(result.is_err(), "Expected error for wrong argument count"); + } + + // ========================================================================= + // Tuple Type Tests + // ========================================================================= + + #[test] + fn test_tuple_type_inference() { + // Tuple literal should have correct type + let result = analyze( + r#" + fn main() -> unit { + pair := (1, 2); + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected tuple literal to pass: {:?}", + result + ); + } + + #[test] + fn test_tuple_mixed_types() { + // Tuple can contain mixed types + let result = analyze( + r#" + fn main() -> unit { + mixed := (42, true); + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected mixed tuple to pass: {:?}", result); + } + + #[test] + fn test_tuple_with_qubits() { + // Tuple of qubit references (for two-qubit gates) + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + cx (q[0], q[1]); + + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected tuple with qubits to pass: {:?}", + result + ); + } + + #[test] + fn test_tuple_type_annotation() { + // Tuple with explicit type annotation (i64 is default int type) + let result = analyze( + r#" + fn main() -> unit { + mut pair: (i64, bool) = (42, true); + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected tuple type annotation to pass: {:?}", + result + ); + } + + #[test] + fn test_tuple_type_annotation_triple() { + // Triple tuple with explicit type annotation + let result = analyze( + r#" + fn main() -> unit { + triple: (i64, i64, i64) = (1, 2, 3); + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected triple tuple type to pass: {:?}", + result + ); + } + + #[test] + fn test_tuple_type_mismatch() { + // Tuple type mismatch should fail + let result = analyze( + r#" + fn main() -> unit { + mut pair: (u32, bool) = (42, true); + + return unit; } + "#, + ); + // Should fail because 42 is i64, not u32 + assert!(result.is_err(), "Expected tuple type mismatch error"); + } + + // ========================================================================= + // Numeric Type Suffix Tests + // ========================================================================= + + #[test] + fn test_int_suffix_u32() { + // Integer with u32 suffix should have u32 type + let result = analyze( + r#" + fn main() -> unit { + x: u32 = 42u32; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected u32 suffix to pass: {:?}", result); + } + + #[test] + fn test_int_suffix_with_underscore() { + // Integer with underscore separator before suffix + let result = analyze( + r#" + fn main() -> unit { + x: u64 = 1000_u64; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected _u64 suffix to pass: {:?}", result); + } + + #[test] + fn test_float_suffix_f32() { + // Float with f32 suffix + let result = analyze( + r#" + fn main() -> unit { + x: f32 = 3.14f32; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected f32 suffix to pass: {:?}", result); + } + + #[test] + fn test_suffix_tuple_match() { + // Suffixes allow tuple type to match + let result = analyze( + r#" + fn main() -> unit { + pair: (u32, bool) = (42u32, true); + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected suffixed tuple to pass: {:?}", + result + ); + } + + #[test] + fn test_suffix_mismatch() { + // Suffix type should not match different declared type + // bool cannot be assigned to an integer type + let result = analyze( + r#" + fn main() -> unit { + x: u32 = true; + + return unit; } + "#, + ); + assert!(result.is_err(), "Expected type mismatch with bool and u32"); + } + + #[test] + fn test_suffix_default_type_vs_suffix() { + // Without suffix, 42 is i64 which won't match (u32, bool) tuple + // With suffix, 42u32 matches (u32, bool) tuple + let result_no_suffix = analyze( + r#" + fn main() -> unit { + pair: (u32, bool) = (42, true); + + return unit; } + "#, + ); + let result_with_suffix = analyze( + r#" + fn main() -> unit { + pair: (u32, bool) = (42u32, true); + + return unit; } + "#, + ); + // Tuple elements require exact type match (no numeric coercion) + // Unsuffixed 42 is i64, so (i64, bool) doesn't match (u32, bool) + assert!( + result_no_suffix.is_err(), + "Expected unsuffixed tuple to fail type check" + ); + // With suffix, types match exactly + assert!( + result_with_suffix.is_ok(), + "Expected suffixed tuple to pass: {:?}", + result_with_suffix + ); + } + + // ========================================================================= + // Tick Block Duplicate Qubit Tests + // ========================================================================= + + #[test] + fn test_tick_no_duplicate_qubits() { + // Valid: different qubits in parallel + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + tick { + h q[0]; + h q[1]; + } + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected no duplicate error: {:?}", result); + } + + #[test] + fn test_tick_duplicate_qubit_same_gate() { + // Invalid: same qubit used twice in parallel + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + tick { + h q[0]; + x q[0]; + } + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::DuplicateQubitInTick { ref allocator, index, .. }) if allocator == "q" && index == 0), + "Expected DuplicateQubitInTick error for q[0]: {:?}", + result + ); + } + + #[test] + fn test_tick_duplicate_in_two_qubit_gate() { + // Invalid: qubit used in both h and cx + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + tick { + h q[0]; + cx (q[0], q[1]); + } + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::DuplicateQubitInTick { .. })), + "Expected DuplicateQubitInTick error: {:?}", + result + ); + } + + #[test] + fn test_tick_no_duplicate_permissive() { + // In permissive mode, duplicates are allowed (no error) + let result = analyze_permissive( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + tick { + h q[0]; + x q[0]; + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Permissive mode should allow duplicate qubits: {:?}", + result + ); + } + + #[test] + fn test_nested_tick_error() { + // Invalid: nested tick blocks + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + tick { + h q[0]; + tick { + x q[1]; + } + } + return unit; + } + "#, + ); + assert!(result.is_err(), "Expected nested tick error"); + let err = result.unwrap_err(); + assert!( + matches!(err, SemanticError::NestedTick { .. }), + "Expected NestedTick error, got: {:?}", + err + ); + } + + #[test] + fn test_sequential_ticks_ok() { + // Valid: sequential tick blocks (not nested) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + tick { h q[0]; } + tick { h q[1]; } + tick { cx (q[0], q[1]); } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Sequential ticks should be valid: {:?}", + result + ); + } + + // ========================================================================= + // Break/Continue Validation Tests + // ========================================================================= + + #[test] + fn test_break_inside_loop() { + // Valid: break inside a for loop + let result = analyze( + r#" + fn main() -> unit { + for i in 0..10 { + if (i == 5) { + break; + } + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected break inside loop to pass: {:?}", + result + ); + } + + #[test] + fn test_continue_inside_loop() { + // Valid: continue inside a for loop + let result = analyze( + r#" + fn main() -> unit { + for i in 0..10 { + if (i == 5) { + continue; + } + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected continue inside loop to pass: {:?}", + result + ); + } + + #[test] + fn test_break_outside_loop() { + // Invalid: break outside of any loop + let result = analyze( + r#" + fn main() -> unit { + break; + + return unit; } + "#, + ); + assert!( + matches!(result, Err(SemanticError::BreakContinueOutsideLoop { ref keyword, .. }) if keyword == "break"), + "Expected BreakContinueOutsideLoop error for break: {:?}", + result + ); + } + + #[test] + fn test_continue_outside_loop() { + // Invalid: continue outside of any loop + let result = analyze( + r#" + fn main() -> unit { + continue; + + return unit; } + "#, + ); + assert!( + matches!(result, Err(SemanticError::BreakContinueOutsideLoop { ref keyword, .. }) if keyword == "continue"), + "Expected BreakContinueOutsideLoop error for continue: {:?}", + result + ); + } + + #[test] + fn test_break_in_nested_loop() { + // Valid: break inside nested loops + let result = analyze( + r#" + fn main() -> unit { + for i in 0..10 { + for j in 0..10 { + if (j == 5) { + break; + } + } + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected break in nested loop to pass: {:?}", + result + ); + } + + #[test] + fn test_for_loop_range_type_inference_default() { + // For loop with plain integer literals defaults to i64 + let result = analyze( + r#" + fn main() -> unit { + for i in 0..10 { + x: i64 = i; // Should work since i is i64 + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected for loop with i64 range to pass: {:?}", + result + ); + } + + #[test] + fn test_for_loop_range_type_inference_u32() { + // For loop with u32 suffix should infer u32 loop variable + let result = analyze( + r#" + fn main() -> unit { + for i in 0u32..10u32 { + x: u32 = i; // Should work since i is u32 + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected for loop with u32 range to pass: {:?}", + result + ); + } + + #[test] + fn test_for_loop_range_type_inference_usize() { + // For loop with usize suffix should infer usize loop variable + let result = analyze( + r#" + fn main() -> unit { + for i in 0_usize..10_usize { + x: usize = i; // Should work since i is usize + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected for loop with usize range to pass: {:?}", + result + ); + } + + #[test] + fn test_array_type_size_literal() { + // Array type with literal size should analyze correctly + let result = analyze( + r#" + fn main() -> unit { + mut arr: [10]u32 = undefined; + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected array with literal size to pass: {:?}", + result + ); + } + + #[test] + fn test_array_type_size_hex_literal() { + // Array type with hex literal size should analyze correctly + let result = analyze( + r#" + fn main() -> unit { + mut arr: [0x10]u8 = undefined; + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected array with hex size to pass: {:?}", + result + ); + } + + #[test] + fn test_array_slice_type() { + // Array slice (no size) should analyze correctly + let result = analyze( + r#" + fn get_slice() -> []u8 { + return undefined; + } + "#, + ); + assert!(result.is_ok(), "Expected slice type to pass: {:?}", result); + } + + #[test] + fn test_const_propagation_array_size() { + // Const propagation: use const value as array size + let result = analyze( + r#" + fn main() -> unit { + N := 10; + mut arr: [N]u32 = undefined; + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected const propagation for array size to pass: {:?}", + result + ); + } + + #[test] + fn test_const_propagation_array_size_with_type() { + // Const propagation with explicit type annotation + let result = analyze( + r#" + fn main() -> unit { + SIZE: usize = 5; + mut buffer: [SIZE]u8 = undefined; + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected typed const propagation to pass: {:?}", + result + ); + } + + #[test] + fn test_const_propagation_chained() { + // Chained propagation: const derived from another const + let result = analyze( + r#" + fn main() -> unit { + A := 4; + B := A; + mut arr: [B]u32 = undefined; + + return unit; } + "#, + ); + assert!( + result.is_ok(), + "Expected chained const propagation to pass: {:?}", + result + ); + } + + // ========================================================================= + // Array and Slice Property Tests + // ========================================================================= + + #[test] + fn test_array_len_property() { + // Array .len returns the compile-time known length + let result = analyze( + r#" + fn main() -> unit { + arr: [10]u32 = undefined; + len := arr.len; + + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected array .len to pass: {:?}", result); + } + + #[test] + fn test_array_ptr_property() { + // Array .ptr returns a pointer to the first element + let result = analyze( + r#" + fn main() -> unit { + arr: [10]u32 = undefined; + ptr := arr.ptr; + + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected array .ptr to pass: {:?}", result); + } + + #[test] + fn test_slice_len_property() { + // Slice .len returns the dynamic length + let result = analyze( + r#" + fn process(data: []u32) -> unit { + len := data.len; + + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected slice .len to pass: {:?}", result); + } + + #[test] + fn test_slice_ptr_property() { + // Slice .ptr returns a pointer to the first element + let result = analyze( + r#" + fn process(data: []u32) -> unit { + ptr := data.ptr; + + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected slice .ptr to pass: {:?}", result); + } + + // ========================================================================= + // Gate Syntax Tests + // ========================================================================= + + #[test] + fn test_valid_gate_syntax() { + // Correct gate syntax: h q[0], cx (q[0], q[1]) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected valid gate syntax to pass: {:?}", + result + ); + } + + #[test] + fn test_qubit_index_out_of_bounds() { + // Access q[5] when allocator only has 2 qubits + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + h q[5]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), + "Expected QubitIndexOutOfBounds error, got: {:?}", + result + ); + } + + #[test] + fn test_qubit_index_at_boundary() { + // Access q[1] when allocator has 2 qubits (valid, 0-indexed) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + h q[1]; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected index at boundary to pass: {:?}", + result + ); + } + + #[test] + fn test_qubit_index_exactly_at_capacity() { + // Access q[2] when allocator has 2 qubits (invalid, 0-indexed means max is 1) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + h q[2]; + return unit; + } + "#, + ); + assert!( + matches!( + result, + Err(SemanticError::QubitIndexOutOfBounds { + index: 2, + capacity: 2, + .. + }) + ), + "Expected QubitIndexOutOfBounds error for index at capacity, got: {:?}", + result + ); + } + + #[test] + fn test_measurement_index_out_of_bounds() { + // Measurement should also catch out-of-bounds + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + r := mz(u1) q[5]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), + "Expected QubitIndexOutOfBounds error for measurement, got: {:?}", + result + ); + } + + #[test] + fn test_measurement_array_index_out_of_bounds() { + // Array measurement with one out-of-bounds index + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + r := mz([2]u1) [q[0], q[5]]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), + "Expected QubitIndexOutOfBounds error for array measurement, got: {:?}", + result + ); + } + + #[test] + fn test_pz_index_out_of_bounds() { + // Prepare specific slots with out-of-bounds index + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz {q[0], q[5]}; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), + "Expected QubitIndexOutOfBounds error for pz, got: {:?}", + result + ); + } + + #[test] + fn test_paren_gate_syntax_valid() { + // h(q[0]) is now valid - parentheses are just grouping, equivalent to h q[0] + // This is more intuitive and consistent with how most languages work + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(1); + pz q; + h(q[0]); + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "h(q[0]) should be valid (parens are grouping): {:?}", + result + ); + } + + #[test] + fn test_cx_tuple_syntax_valid() { + // cx(q[0], q[1]) is valid - tuple target for two-qubit gate + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + cx(q[0], q[1]); + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "cx(q[0], q[1]) should be valid: {:?}", + result + ); + } + + #[test] + fn test_rx_without_angle_error() { + // rx is a parameterized gate, it REQUIRES an angle parameter + // rx q[0] without angle should fail at parse time + let source = r#" + fn main() -> unit { + q := qalloc(1); + pz q; + rx q[0]; + return unit; + } + "#; + let result = parse(source); + assert!( + result.is_err(), + "rx without angle should fail at parse time" + ); + } + + // ========================================================================= + // Type Ascription Tests (space-separated: `42 u32`, `1/4 f64`) + // ========================================================================= + + #[test] + fn test_type_ascription_simple() { + // Type ascription with space: `42 u32` + let result = analyze( + r#" + fn main() -> unit { + x: u32 = 42 u32; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected type ascription `42 u32` to pass: {:?}", + result + ); + } + + #[test] + fn test_type_ascription_float() { + // Type ascription with float: `3.14 f64` + let result = analyze( + r#" + fn main() -> unit { + x: f64 = 3.14 f64; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected type ascription `3.14 f64` to pass: {:?}", + result + ); + } + + #[test] + fn test_type_ascription_expression() { + // Type ascription on expression: `1/4 f64` should be 0.25 + let result = analyze( + r#" + fn main() -> unit { + x: f64 = 1/4 f64; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected type ascription `1/4 f64` to pass: {:?}", + result + ); + } + + // ========================================================================= + // Angle Literal Tests (`0.25 turns`, `pi/4 rad`) + // ========================================================================= + + #[test] + fn test_angle_literal_turns() { + // Angle literal with turns unit + let result = analyze( + r#" + fn main() -> unit { + angle: a64 = 0.25 turns; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected angle literal `0.25 turns` to pass: {:?}", + result + ); + } + + #[test] + fn test_angle_literal_half_turn() { + // Half turn + let result = analyze( + r#" + fn main() -> unit { + angle: a64 = 0.5 turns; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected angle literal `0.5 turns` to pass: {:?}", + result + ); + } + + #[test] + fn test_angle_literal_type_is_a64() { + // Angle literals should be type a64 + let result = analyze( + r#" + fn main() -> unit { + angle := 0.25 turns; + check: a64 = angle; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected angle literal to be a64 type: {:?}", + result + ); + } + + // ========================================================================= + // Error Reporting Tests - Ensure errors are not silently swallowed + // ========================================================================= + + #[test] + fn test_undefined_symbol_in_binding_reports_error() { + // Undefined symbols in bindings should report an error, not silently use Type::Unknown + let result = analyze("x := undefined_symbol;"); + assert!(result.is_err(), "Expected undefined symbol error"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::UndefinedSymbol { .. }), + "Expected UndefinedSymbol, got {:?}", + e + ); + } + } + + #[test] + fn test_undefined_symbol_in_struct_init_reports_error() { + // Undefined symbols in struct field values should report an error + let result = analyze( + r#" + fn main() -> unit { + s := .{ x: undefined_symbol }; + return unit; + } + "#, + ); + assert!( + result.is_err(), + "Expected undefined symbol error in struct init" + ); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::UndefinedSymbol { .. }), + "Expected UndefinedSymbol, got {:?}", + e + ); + } + } + + #[test] + fn test_direct_recursion_rejected() { + // Direct recursion should be rejected in strict mode (NASA Power of 10 compliance) + let result = analyze_strict( + r#" + fn factorial(n: u32) -> u32 { + if n == 0 { + return 1; + } + return n * factorial(n - 1); + } + "#, + ); + assert!(result.is_err(), "Expected recursion error"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::RecursionDetected { .. }), + "Expected RecursionDetected, got {:?}", + e + ); + } + } + + #[test] + fn test_recursion_rejected_even_in_permissive_mode() { + // Recursion is always rejected (safe-by-constraint, no escape hatch) + // Use FFI with Rust if recursive algorithms are needed + let result = analyze_permissive( + r#" + fn factorial(n: u32) -> u32 { + if n == 0 { + return 1; + } + return n * factorial(n - 1); + } + "#, + ); + assert!( + result.is_err(), + "Recursion should be rejected even in permissive mode" + ); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::RecursionDetected { .. }), + "Expected RecursionDetected, got {:?}", + e + ); + } + } + + #[test] + fn test_non_recursive_function_allowed() { + // Non-recursive functions should be allowed + let result = analyze( + r#" + fn helper() -> u32 { + return 42; + } + fn main() -> u32 { + return helper(); + } + "#, + ); + assert!( + result.is_ok(), + "Expected non-recursive call to pass: {:?}", + result + ); + } + + #[test] + fn test_catch_on_non_error_type_rejected() { + // catch on non-error types should be rejected + let result = analyze( + r#" + fn main() -> u32 { + x := 42 catch 0; + return x; + } + "#, + ); + assert!(result.is_err(), "Expected catch on non-error type to fail"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::CatchOnNonErrorType { .. }), + "Expected CatchOnNonErrorType, got {:?}", + e + ); + } + } + + #[test] + fn test_catch_on_error_union_allowed() { + // catch on error union types should be allowed + let result = analyze( + r#" + MyError := error { Fail }; + fn might_fail() -> MyError!u32 { + return 42; + } + fn main() -> u32 { + x := might_fail() catch 0; + return x; + } + "#, + ); + assert!( + result.is_ok(), + "Expected catch on error union to pass: {:?}", + result + ); + } + + #[test] + fn test_batch_gate_single_qubit_correct_arity() { + // Single qubit gate with single qubit targets should pass + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + h { q[0], q[1], q[2] }; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected single qubit batch gate to pass: {:?}", + result + ); + } + + #[test] + fn test_batch_gate_two_qubit_correct_arity() { + // Two qubit gate with pair targets should pass + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + cx { (q[0], q[1]), (q[2], q[3]) }; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected two qubit batch gate to pass: {:?}", + result + ); + } + + #[test] + fn test_batch_gate_two_qubit_wrong_arity() { + // Two qubit gate with single qubit target should fail + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + cx { q[0] }; + return unit; + } + "#, + ); + assert!( + result.is_err(), + "Expected two qubit gate with single qubit to fail" + ); + if let Err(e) = result { + assert!( + matches!( + e, + SemanticError::GateArityMismatch { + expected: 2, + found: 1, + .. + } + ), + "Expected GateArityMismatch, got {:?}", + e + ); + } + } + + #[test] + fn test_batch_gate_single_qubit_wrong_arity() { + // Single qubit gate with pair target should fail + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + h { (q[0], q[1]) }; + return unit; + } + "#, + ); + assert!( + result.is_err(), + "Expected single qubit gate with pair to fail" + ); + if let Err(e) = result { + assert!( + matches!( + e, + SemanticError::GateArityMismatch { + expected: 1, + found: 2, + .. + } + ), + "Expected GateArityMismatch, got {:?}", + e + ); + } + } + + // ========================================================================= + // ResolvedType and contains_unknown Tests + // ========================================================================= + + #[test] + fn test_contains_unknown_primitive_types() { + // Primitives never contain Unknown + assert!(!Type::Bool.contains_unknown()); + assert!( + !Type::UInt { + bits: BitWidth::BITS_32 + } + .contains_unknown() + ); + assert!( + !Type::IInt { + bits: BitWidth::BITS_64 + } + .contains_unknown() + ); + assert!(!Type::F64.contains_unknown()); + assert!(!Type::Qubit.contains_unknown()); + assert!(!Type::Unit.contains_unknown()); + assert!(!Type::Never.contains_unknown()); + } + + #[test] + fn test_contains_unknown_direct() { + // Unknown itself contains unknown + assert!(Type::Unknown.contains_unknown()); + } + + #[test] + fn test_contains_unknown_nested_in_array() { + // Array with Unknown element contains unknown + let arr_unknown = Type::Array { + element: Box::new(Type::Unknown), + size: Some(10), + }; + assert!(arr_unknown.contains_unknown()); + + // Array with concrete element doesn't contain unknown + let arr_u32 = Type::Array { + element: Box::new(Type::UInt { + bits: BitWidth::BITS_32, + }), + size: Some(10), + }; + assert!(!arr_u32.contains_unknown()); + } + + #[test] + fn test_contains_unknown_nested_in_tuple() { + // Tuple with Unknown element + let tuple_with_unknown = Type::Tuple { + elements: vec![ + Type::UInt { + bits: BitWidth::BITS_32, + }, + Type::Unknown, + Type::Bool, + ], + }; + assert!(tuple_with_unknown.contains_unknown()); + + // Tuple without Unknown + let tuple_concrete = Type::Tuple { + elements: vec![ + Type::UInt { + bits: BitWidth::BITS_32, + }, + Type::Bool, + ], + }; + assert!(!tuple_concrete.contains_unknown()); + } + + #[test] + fn test_contains_unknown_deeply_nested() { + // Unknown deeply nested: Array(Optional(Unknown)) + let deeply_nested = Type::Array { + element: Box::new(Type::Optional { + inner: Box::new(Type::Unknown), + }), + size: Some(5), + }; + assert!(deeply_nested.contains_unknown()); + } + + #[test] + fn test_contains_unknown_error_union() { + // Unknown in error position + let unknown_error = Type::ErrorUnion { + error: Box::new(Type::Unknown), + payload: Box::new(Type::UInt { + bits: BitWidth::BITS_32, + }), + }; + assert!(unknown_error.contains_unknown()); + + // Unknown in payload position + let unknown_payload = Type::ErrorUnion { + error: Box::new(Type::AnyError), + payload: Box::new(Type::Unknown), + }; + assert!(unknown_payload.contains_unknown()); + + // Neither contains Unknown + let concrete_union = Type::ErrorUnion { + error: Box::new(Type::AnyError), + payload: Box::new(Type::UInt { + bits: BitWidth::BITS_32, + }), + }; + assert!(!concrete_union.contains_unknown()); + } + + #[test] + fn test_resolve_success() { + // Concrete type resolves successfully + let ty = Type::UInt { + bits: BitWidth::BITS_32, + }; + let resolved = ty.resolve(); + assert!(resolved.is_some()); + assert_eq!(resolved.unwrap().display_name(), "u32"); + } + + #[test] + fn test_resolve_failure_direct() { + // Unknown type fails to resolve + let ty = Type::Unknown; + assert!(ty.resolve().is_none()); + } + + #[test] + fn test_resolve_failure_nested() { + // Type containing Unknown fails to resolve + let ty = Type::Optional { + inner: Box::new(Type::Unknown), + }; + assert!(ty.resolve().is_none()); + } + + #[test] + fn test_resolved_type_methods() { + let ty = Type::UInt { + bits: BitWidth::BITS_64, + }; + let resolved = ty.resolve().unwrap(); + + // Check wrapper methods work correctly + assert!(resolved.is_numeric()); + assert!(resolved.is_integer()); + assert!(!resolved.is_float()); + assert!(!resolved.is_quantum()); + assert_eq!(resolved.display_name(), "u64"); + } + + #[test] + fn test_is_resolved() { + assert!(Type::Bool.is_resolved()); + assert!(Type::F64.is_resolved()); + assert!(!Type::Unknown.is_resolved()); + + let nested = Type::Array { + element: Box::new(Type::Unknown), + size: None, + }; + assert!(!nested.is_resolved()); + } + + // ========================================================================= + // Undefined Type Error Tests + // ========================================================================= + + #[test] + fn test_undefined_type_in_variable_declaration() { + // Using undefined type should produce an error + let result = analyze( + r#" + fn main() -> unit { + x: UndefinedType = 42; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::UndefinedType { ref name, .. }) if name == "UndefinedType"), + "Expected UndefinedType error, got: {:?}", + result + ); + } + + #[test] + fn test_undefined_type_in_function_param() { + // Using undefined type in function parameter should produce an error + let result = analyze( + r#" + fn foo(x: NonexistentType) -> unit { + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::UndefinedType { ref name, .. }) if name == "NonexistentType"), + "Expected UndefinedType error, got: {:?}", + result + ); + } + + #[test] + fn test_undefined_type_in_return_type() { + // Using undefined type in return type should produce an error + let result = analyze( + r#" + fn foo() -> MissingType { + return 42; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::UndefinedType { ref name, .. }) if name == "MissingType"), + "Expected UndefinedType error, got: {:?}", + result + ); + } + + #[test] + fn test_defined_type_works() { + // Defined types should work correctly + let result = analyze( + r#" + MyStruct := struct { x: u32 }; + fn main() -> unit { + s: MyStruct = MyStruct { x: 42 }; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected defined type to work: {:?}", + result + ); + } + + // ========================================================================= + // Input Size Limit Tests + // ========================================================================= + + #[test] + fn test_scope_nesting_within_limit() { + // Normal scope nesting should work + let result = analyze( + r#" + fn main() -> unit { + { + { + { + x := 42; + } + } + } + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected normal nesting to work: {:?}", + result + ); + } + + #[test] + fn test_symbol_table_normal_usage() { + // Normal symbol usage should work + let result = analyze( + r#" + fn main() -> unit { + a := 1; + b := 2; + c := 3; + d := 4; + e := 5; + return unit; + } + "#, + ); + assert!( + result.is_ok(), + "Expected normal symbol usage to work: {:?}", + result + ); + } + + #[test] + fn test_max_scope_depth_constant() { + // Verify the constant is reasonable (checked at compile time). + const { + assert!( + MAX_SCOPE_DEPTH >= 64, + "MAX_SCOPE_DEPTH should be at least 64" + ); + assert!( + MAX_SCOPE_DEPTH <= 1024, + "MAX_SCOPE_DEPTH should not be excessive" + ); + } + } + + #[test] + fn test_max_symbol_count_constant() { + // Verify the constant is reasonable (checked at compile time). + const { + assert!( + MAX_SYMBOL_COUNT >= 10_000, + "MAX_SYMBOL_COUNT should be at least 10000" + ); + assert!( + MAX_SYMBOL_COUNT <= 10_000_000, + "MAX_SYMBOL_COUNT should not be excessive" + ); + } + } + + // ========================================================================= + // Multi-Error Collection Tests + // ========================================================================= + + #[test] + fn test_analyze_collecting_errors_multiple() { + // Program with multiple errors should collect them all + let source = r#" + fn foo() -> UndefinedType1 { + return 42; + } + fn bar() -> UndefinedType2 { + return 42; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze_collecting_errors(&program); + + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!( + errors.len() >= 2, + "Expected at least 2 errors, got {}", + errors.len() + ); + + // Check that both undefined types are reported + let error_names: Vec<_> = errors + .iter() + .filter_map(|e| { + if let SemanticError::UndefinedType { name, .. } = e { + Some(name.as_str()) + } else { + None + } + }) + .collect(); + assert!( + error_names.contains(&"UndefinedType1"), + "Should report UndefinedType1" + ); + assert!( + error_names.contains(&"UndefinedType2"), + "Should report UndefinedType2" + ); + } + + #[test] + fn test_analyze_collecting_errors_none() { + // Valid program should have no errors + let source = r#" + fn main() -> unit { + return unit; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze_collecting_errors(&program); + + assert!(result.is_ok(), "Expected no errors: {:?}", result); + } + + #[test] + fn test_semantic_errors_display() { + // Test the Display impl for SemanticErrors + let errors = SemanticErrors::new(vec![ + SemanticError::UndefinedType { + name: "Foo".to_string(), + location: SourceLocation::default(), + }, + SemanticError::UndefinedType { + name: "Bar".to_string(), + location: SourceLocation::default(), + }, + ]); + + let display = format!("{}", errors); + assert!(display.contains("2 error(s)")); + assert!(display.contains("Foo")); + assert!(display.contains("Bar")); + } + + #[test] + fn test_semantic_errors_iter() { + let errors = SemanticErrors::new(vec![ + SemanticError::UndefinedType { + name: "A".to_string(), + location: SourceLocation::default(), + }, + SemanticError::UndefinedType { + name: "B".to_string(), + location: SourceLocation::default(), + }, + ]); + + assert_eq!(errors.len(), 2); + assert!(!errors.is_empty()); + + let names: Vec<_> = errors + .iter() + .filter_map(|e| { + if let SemanticError::UndefinedType { name, .. } = e { + Some(name.clone()) + } else { + None + } + }) + .collect(); + assert_eq!(names, vec!["A", "B"]); + } + + #[test] + fn test_error_count_and_take() { + let source = r#" + fn foo(x: UndefinedType) -> unit { + return unit; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let _ = analyzer.analyze(&program); // Ignore result + + // Errors should have been collected + assert!(analyzer.error_count() > 0); + + // Take errors should empty the list + let taken = analyzer.take_errors(); + assert!(!taken.is_empty()); + assert_eq!(analyzer.error_count(), 0); + } + + #[test] + fn test_slice_element_access() { + // Test that indexing a slice parameter with an integer returns element type + // NOTE: Variable name must NOT be a gate name (s, h, x, y, z, t are gates) + let source = r#" + fn get_element(data: []i32) -> i32 { + return data[0]; + } + "#; + let result = analyze(source); + assert!( + result.is_ok(), + "Slice element access should work: {:?}", + result.err() + ); + } + + // ========================================================================= + // Inline For Loop Validation Tests + // ========================================================================= + + #[test] + fn test_inline_for_valid() { + // Valid inline for with comptime range + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..4 { + h q[i]; + } + return unit; + } + "#; + let result = analyze(source); + assert!( + result.is_ok(), + "Valid inline for should pass: {:?}", + result.err() + ); + } + + #[test] + fn test_inline_for_with_comptime_expr() { + // Valid inline for with comptime expression bound + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..(2 * 2) { + h q[i]; + } + return unit; + } + "#; + let result = analyze(source); + assert!( + result.is_ok(), + "Inline for with comptime expr should pass: {:?}", + result.err() + ); + } + + #[test] + fn test_inline_for_error_break() { + // break is not allowed in inline for + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..4 { + h q[i]; + break; + } + return unit; + } + "#; + let result = analyze(source); + assert!( + matches!(result, Err(SemanticError::BreakInInlineFor { .. })), + "Expected BreakInInlineFor error, got {:?}", + result + ); + } + + #[test] + fn test_inline_for_error_continue() { + // continue is not allowed in inline for + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..4 { + h q[i]; + continue; + } + return unit; + } + "#; + let result = analyze(source); + assert!( + matches!(result, Err(SemanticError::ContinueInInlineFor { .. })), + "Expected ContinueInInlineFor error, got {:?}", + result + ); + } + + #[test] + fn test_inline_for_error_non_comptime_range() { + // Runtime variable in range is not allowed + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + mut n := 4; + inline for i in 0..n { + h q[i]; + } + return unit; + } + "#; + let result = analyze(source); + assert!( + matches!(result, Err(SemanticError::InlineForRangeNotComptime { .. })), + "Expected InlineForRangeNotComptime error, got {:?}", + result + ); + } + + #[test] + fn test_inline_for_nested_break_in_regular_for() { + // break in a regular for inside inline for should be allowed + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..2 { + for j in 0..10 { + if j == 5 { + break; + } + } + } + return unit; + } + "#; + let result = analyze(source); + // This should fail because we're still inside an inline for + // and break applies to the inner regular for + assert!( + matches!(result, Err(SemanticError::BreakInInlineFor { .. })), + "Break in nested regular for inside inline for should fail: {:?}", + result + ); + } + + // ========================================================================= + // Generic Type Instantiation Tests + // ========================================================================= + + #[test] + fn test_mangle_generic_name() { + use crate::comptime::ComptimeValue; + + let args = vec![ + ComptimeValue::Type(Type::UInt { + bits: BitWidth::must(32), + }), + ComptimeValue::Uint(4), + ]; + let mangled = SemanticAnalyzer::mangle_generic_name("make_array", &args); + assert!(mangled.starts_with("make_array__CT__")); + assert!(mangled.contains("u32") || mangled.contains("UInt")); + assert!(mangled.contains("4")); + } + + #[test] + fn test_serialize_comptime_args() { + use crate::comptime::ComptimeValue; + + let args = vec![ComptimeValue::Int(42), ComptimeValue::Bool(true)]; + let serialized = SemanticAnalyzer::serialize_comptime_args(&args); + assert!(serialized.contains("42")); + assert!(serialized.contains("true")); + } + + #[test] + fn test_generic_function_detection() { + // Test that functions with comptime params are detected + // Use a comptime integer parameter since comptime type params need more work + let source = r#" + fn repeat(comptime N: u32, val: i32) -> i32 { + return val; + } + "#; + let program = crate::parse(source).unwrap(); + + // First verify the parser detected the comptime param + if let crate::ast::TopLevelDecl::Fn(fn_decl) = &program.declarations[0] { + assert_eq!(fn_decl.params.len(), 2); + assert!( + fn_decl.params[0].is_comptime, + "First param should be comptime (parser should set this). Got: {:?}", + fn_decl.params[0] + ); + assert!( + !fn_decl.params[1].is_comptime, + "Second param should not be comptime" + ); + } else { + panic!("Expected function declaration"); + } + + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Generic function should parse: {:?}", + result.err() + ); + + // Check that the function was registered with comptime param info + if let Some(symbol) = analyzer.symbols.lookup("repeat") { + if let SymbolKind::Function { + comptime_param_indices, + original_decl, + .. + } = &symbol.kind + { + assert_eq!( + comptime_param_indices.len(), + 1, + "Should have 1 comptime param" + ); + assert_eq!( + comptime_param_indices[0], 0, + "First param should be comptime" + ); + assert!( + original_decl.is_some(), + "Should store original decl for generic" + ); + } else { + panic!("Expected function symbol"); + } + } else { + panic!("Function not found in symbol table"); + } + } + + #[test] + fn test_non_generic_function_no_original_decl() { + // Test that regular functions don't store original_decl + let source = r#" + fn add(a: i32, b: i32) -> i32 { + return a + b; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let _ = analyzer.analyze(&program); + + if let Some(symbol) = analyzer.symbols.lookup("add") + && let SymbolKind::Function { + comptime_param_indices, + original_decl, + .. + } = &symbol.kind + { + assert!( + comptime_param_indices.is_empty(), + "Should have no comptime params" + ); + assert!( + original_decl.is_none(), + "Should not store original decl for non-generic" + ); + } + } + + // ========================================================================= + // Alias Tests + // ========================================================================= + + #[test] + fn test_alias_basic() { + // Basic alias should parse and analyze + let source = r#" + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias view := arr[0..4]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Basic alias should analyze: {:?}", result); + } + + #[test] + fn test_alias_non_overlapping() { + // Non-overlapping aliases on same source should work + let source = r#" + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias first := arr[0..4]; + alias second := arr[4..8]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Non-overlapping aliases should work: {:?}", + result + ); + } + + #[test] + fn test_alias_overlapping_error() { + // Overlapping aliases should be an error + let source = r#" + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias first := arr[0..4]; + alias second := arr[2..6]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + matches!(result, Err(SemanticError::OverlappingAlias(_))), + "Overlapping aliases should error: {:?}", + result + ); + } + + #[test] + fn test_alias_adjacent_ranges() { + // Adjacent ranges [0..4) and [4..8) should not overlap + let source = r#" + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias a := arr[0..4]; + alias b := arr[4..8]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Adjacent ranges should not overlap: {:?}", + result + ); + } + + #[test] + fn test_alias_different_sources() { + // Aliases on different sources can have overlapping ranges + let source = r#" + pub fn main() -> unit { + arr1: [8]u32 = undefined; + arr2: [8]u32 = undefined; + alias a := arr1[0..4]; + alias b := arr2[0..4]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Different sources can have same ranges: {:?}", + result + ); + } + + #[test] + fn test_alias_usable_as_value() { + // Alias should be usable like a slice + let source = r#" + fn take_slice(data: []u32) -> unit { + return; + } + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias view := arr[0..4]; + take_slice(view); + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Alias should be usable as slice: {:?}", + result + ); + } + + #[test] + fn test_ranges_overlap_function() { + // Test the ranges_overlap helper + assert!(SemanticAnalyzer::ranges_overlap((0, 4), (2, 6))); // Overlap + assert!(SemanticAnalyzer::ranges_overlap((2, 6), (0, 4))); // Overlap (reversed) + assert!(!SemanticAnalyzer::ranges_overlap((0, 4), (4, 8))); // Adjacent, no overlap + assert!(!SemanticAnalyzer::ranges_overlap((0, 2), (5, 8))); // Disjoint + assert!(SemanticAnalyzer::ranges_overlap((0, 10), (5, 6))); // Contained + } + + // ========================================================================= + // Array bounds checking + // ========================================================================= + + #[test] + fn test_array_index_out_of_bounds() { + let result = analyze( + r#" + fn main() -> unit { + arr: [3]i64 = [1, 2, 3]; + x := arr[5]; + return; + } + "#, + ); + assert!(result.is_err(), "Expected ArrayIndexOutOfBounds error"); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("out of bounds"), + "Error message should mention out of bounds, got: {err}", + ); + } + + #[test] + fn test_array_index_at_boundary() { + // arr[2] on [3]i64 is valid (indices 0, 1, 2) + let result = analyze( + r#" + fn main() -> unit { + arr: [3]i64 = [1, 2, 3]; + x := arr[2]; + return; + } + "#, + ); + assert!( + result.is_ok(), + "arr[2] on [3]i64 should be valid, got: {:?}", + result + ); + } + + #[test] + fn test_array_index_literal_at_size() { + // arr[3] on [3]i64 is out of bounds (valid indices are 0, 1, 2) + let result = analyze( + r#" + fn main() -> unit { + arr: [3]i64 = [1, 2, 3]; + x := arr[3]; + return; + } + "#, + ); + assert!( + result.is_err(), + "Expected ArrayIndexOutOfBounds for index == size" + ); + } + + #[test] + fn test_array_dynamic_index_no_error() { + // Dynamic index should not produce a compile-time error + assert!( + analyze( + r#" + fn get(arr: [3]i64, i: i64) -> i64 { + return arr[i]; + } + "# + ) + .is_ok() + ); + } + + // ========================================================================= + // Custom gate declarations + // ========================================================================= + + #[test] + fn test_declare_gate_registered() { + // declare gate should be registered in the gate registry + assert!( + analyze( + r#" + declare gate my_rx(theta)(q); + "# + ) + .is_ok() + ); + } + + #[test] + fn test_declare_gate_no_params() { + assert!( + analyze( + r#" + declare gate my_x()(q); + "# + ) + .is_ok() + ); + } + + #[test] + fn test_declare_gate_multi_qubit() { + assert!( + analyze( + r#" + declare gate cnot()(control, target); + "# + ) + .is_ok() + ); + } + + #[test] + fn test_composite_gate_basic() { + assert!( + analyze( + r#" + gate my_h()(q) { + h q; + } + "# + ) + .is_ok() + ); + } + + #[test] + fn test_composite_gate_multi_qubit() { + assert!( + analyze( + r#" + gate bell()(q0, q1) { + h q0; + cx (q0, q1); + } + "# + ) + .is_ok() + ); + } + + #[test] + fn test_declare_gate_duplicate_rejected() { + // Defining same gate name twice should fail + let result = analyze( + r#" + declare gate my_gate()(q); + declare gate my_gate()(q); + "#, + ); + assert!(result.is_err(), "Duplicate gate declaration should fail"); + } + + #[test] + fn test_declare_then_define_same_gate_rejected() { + // `declare gate` is an opaque target gate, not a forward declaration: + // declaring then defining the same name is a duplicate, not a definition. + let err = analyze( + r#" + declare gate foo()(q); + gate foo()(q) { h q; } + "#, + ) + .expect_err("declare-then-define of the same gate should fail"); + assert!( + err.to_string().contains("target gate"), + "error should explain the gate is already a target declaration: {err}" + ); + } + + #[test] + fn test_define_then_define_same_gate_rejected() { + // Two composite definitions of the same name collide. + let err = analyze( + r#" + gate foo()(q) { h q; } + gate foo()(q) { x q; } + "#, + ) + .expect_err("defining the same gate twice should fail"); + assert!( + err.to_string().contains("already defined"), + "error should report the gate is already defined: {err}" + ); + } + + #[test] + fn test_declare_gate_builtin_exact_signature_allowed() { + // Redeclaring a built-in with its exact signature is a harmless no-op. + // `rz` is a 1-parameter, 1-qubit built-in. + assert!( + analyze( + r#" + declare gate rz(angle)(q); + "# + ) + .is_ok() + ); + } + + #[test] + fn test_declare_gate_builtin_mismatched_signature_rejected() { + // `rz` is a 1-parameter built-in; redeclaring it with no parameters + // would be uncallable under the fixed built-in parameterization. + assert!( + analyze( + r#" + declare gate rz()(q); + "# + ) + .is_err() + ); + } + + #[test] + fn test_builtin_gates_still_work() { + // Built-in gates should still work alongside custom gate declarations + assert!( + analyze( + r#" + declare gate custom_rx(theta)(q); + + fn apply(q: qubit) -> unit { + h q; + x q; + return; + } + "# + ) + .is_ok() + ); + } +} diff --git a/exp/zlup/src/test_runner.rs b/exp/zlup/src/test_runner.rs new file mode 100644 index 000000000..52cd36522 --- /dev/null +++ b/exp/zlup/src/test_runner.rs @@ -0,0 +1,315 @@ +//! Built-in test runner for Zluppy programs. +//! +//! Discovers `test "name" { ... }` blocks in Zluppy source files, +//! analyzes them, and runs classical tests via the comptime evaluator. + +use std::time::{Duration, Instant}; + +use crate::ast::{Program, TestDecl, TopLevelDecl}; +use crate::semantic::SemanticAnalyzer; + +/// Configuration for the test runner. +#[derive(Debug, Clone, Default)] +pub struct TestRunConfig { + /// Only run tests matching this pattern (substring match) + pub filter: Option, + /// Enable strict mode (NASA Power of 10) + pub strict: bool, + /// Print verbose output + pub verbose: bool, +} + +/// Outcome of a single test. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TestOutcome { + Pass, + Fail(String), + Skip(String), +} + +/// Result of running a single test. +#[derive(Debug, Clone)] +pub struct TestResult { + pub name: String, + pub outcome: TestOutcome, + pub duration: Duration, +} + +/// Test runner for Zluppy programs. +pub struct TestRunner { + config: TestRunConfig, +} + +impl TestRunner { + /// Create a new test runner with the given configuration. + pub fn new(config: TestRunConfig) -> Self { + Self { config } + } + + /// Discover test declarations from a program AST. + pub fn discover_tests<'a>(&self, program: &'a Program) -> Vec<&'a TestDecl> { + let mut tests = Vec::new(); + for decl in &program.declarations { + if let TopLevelDecl::Test(test) = decl { + if let Some(ref filter) = self.config.filter + && !test.name.contains(filter.as_str()) + { + continue; + } + tests.push(test); + } + } + tests + } + + /// Run all discovered tests in a program. + pub fn run(&self, program: &Program) -> Vec { + let tests = self.discover_tests(program); + let mut results = Vec::new(); + + // Run each test — skip quantum tests, try semantic analysis for classical ones + for test in &tests { + let result = self.run_single_test(test, program); + results.push(result); + } + + results + } + + /// Run a single test. + fn run_single_test(&self, test: &TestDecl, program: &Program) -> TestResult { + let start = Instant::now(); + + // Check if the test body contains quantum operations + // The comptime evaluator cannot run quantum gates + if contains_quantum_ops(&test.body) { + return TestResult { + name: test.name.clone(), + outcome: TestOutcome::Skip( + "test contains quantum operations (gates/measurements) which cannot be evaluated at compile time".to_string(), + ), + duration: start.elapsed(), + }; + } + + // Run semantic analysis on the program + let mut analyzer = SemanticAnalyzer::new(); + if self.config.strict { + analyzer.set_strict_mode(true); + } + if let Err(err) = analyzer.analyze(program) { + return TestResult { + name: test.name.clone(), + outcome: TestOutcome::Fail(format!("semantic error: {}", err)), + duration: start.elapsed(), + }; + } + + // For now, tests that pass semantic analysis and don't contain + // quantum operations are considered passing. Full comptime evaluation + // of test bodies will be added when the comptime evaluator supports + // statement-level evaluation. + TestResult { + name: test.name.clone(), + outcome: TestOutcome::Pass, + duration: start.elapsed(), + } + } +} + +/// Check if a block contains quantum operations. +fn contains_quantum_ops(block: &crate::ast::Block) -> bool { + use crate::ast::Stmt; + for stmt in &block.statements { + match stmt { + Stmt::Gate(_) | Stmt::Prepare(_) | Stmt::Measure(_) | Stmt::Barrier(_) => { + return true; + } + Stmt::Expr(expr_stmt) if expr_contains_quantum(&expr_stmt.expr) => { + return true; + } + Stmt::If(if_stmt) => { + if contains_quantum_ops(&if_stmt.then_body) { + return true; + } + if let Some(ref else_branch) = if_stmt.else_body { + match else_branch { + crate::ast::ElseBranch::Else(block) => { + if contains_quantum_ops(block) { + return true; + } + } + crate::ast::ElseBranch::ElseIf(if_stmt) => { + if contains_quantum_ops(&if_stmt.then_body) { + return true; + } + } + } + } + } + Stmt::For(for_stmt) if contains_quantum_ops(&for_stmt.body) => { + return true; + } + Stmt::Block(block) if contains_quantum_ops(block) => { + return true; + } + Stmt::Tick(tick) => { + // Tick blocks always contain quantum ops + let _ = tick; + return true; + } + _ => {} + } + } + false +} + +/// Check if an expression contains quantum operations (gate calls, qalloc, etc.). +fn expr_contains_quantum(expr: &crate::ast::Expr) -> bool { + matches!(expr, crate::ast::Expr::Gate(_)) +} + +/// Format test results for terminal output. +pub fn format_results(results: &[TestResult]) -> String { + let mut out = String::new(); + + for result in results { + let status = match &result.outcome { + TestOutcome::Pass => "PASS", + TestOutcome::Fail(_) => "FAIL", + TestOutcome::Skip(_) => "SKIP", + }; + + out.push_str(&format!( + " {} {} ({:.3}ms)\n", + status, + result.name, + result.duration.as_secs_f64() * 1000.0, + )); + + match &result.outcome { + TestOutcome::Fail(msg) => { + out.push_str(&format!(" {}\n", msg)); + } + TestOutcome::Skip(reason) => { + out.push_str(&format!(" {}\n", reason)); + } + _ => {} + } + } + + let pass_count = results + .iter() + .filter(|r| r.outcome == TestOutcome::Pass) + .count(); + let fail_count = results + .iter() + .filter(|r| matches!(r.outcome, TestOutcome::Fail(_))) + .count(); + let skip_count = results + .iter() + .filter(|r| matches!(r.outcome, TestOutcome::Skip(_))) + .count(); + let total = results.len(); + + out.push_str(&format!( + "\n{} tests: {} passed, {} failed, {} skipped\n", + total, pass_count, fail_count, skip_count, + )); + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + #[test] + fn test_discover_tests() { + let source = r#" + test "addition works" { + x := 1 + 1; + } + test "subtraction works" { + x := 2 - 1; + } + fn main() -> unit { return; } + "#; + let program = parse(source).unwrap(); + let runner = TestRunner::new(TestRunConfig::default()); + let tests = runner.discover_tests(&program); + assert_eq!(tests.len(), 2); + assert_eq!(tests[0].name, "addition works"); + assert_eq!(tests[1].name, "subtraction works"); + } + + #[test] + fn test_filter_by_name() { + let source = r#" + test "addition works" { + x := 1 + 1; + } + test "subtraction works" { + x := 2 - 1; + } + "#; + let program = parse(source).unwrap(); + let runner = TestRunner::new(TestRunConfig { + filter: Some("addition".to_string()), + ..Default::default() + }); + let tests = runner.discover_tests(&program); + assert_eq!(tests.len(), 1); + assert_eq!(tests[0].name, "addition works"); + } + + #[test] + fn test_passing_test() { + let source = r#" + test "simple math" { + x := 1 + 1; + } + "#; + let program = parse(source).unwrap(); + let runner = TestRunner::new(TestRunConfig::default()); + let results = runner.run(&program); + assert_eq!(results.len(), 1); + assert_eq!(results[0].outcome, TestOutcome::Pass); + } + + #[test] + fn test_quantum_test_skipped() { + let source = r#" + test "quantum test" { + mut q := qalloc(2); + h q[0]; + } + "#; + let program = parse(source).unwrap(); + let runner = TestRunner::new(TestRunConfig::default()); + let results = runner.run(&program); + assert_eq!(results.len(), 1); + assert!(matches!(results[0].outcome, TestOutcome::Skip(_))); + } + + #[test] + fn test_format_results() { + let results = vec![ + TestResult { + name: "test 1".to_string(), + outcome: TestOutcome::Pass, + duration: Duration::from_millis(1), + }, + TestResult { + name: "test 2".to_string(), + outcome: TestOutcome::Fail("assertion failed".to_string()), + duration: Duration::from_millis(2), + }, + ]; + let output = format_results(&results); + assert!(output.contains("PASS")); + assert!(output.contains("FAIL")); + assert!(output.contains("2 tests: 1 passed, 1 failed, 0 skipped")); + } +} diff --git a/exp/zlup/src/tests.rs b/exp/zlup/src/tests.rs new file mode 100644 index 000000000..5cd891ac4 --- /dev/null +++ b/exp/zlup/src/tests.rs @@ -0,0 +1,1800 @@ +//! Comprehensive tests for Zluppy grammar and parser. + +use crate::parser::parse; + +// ============================================================================= +// Helper macros +// ============================================================================= + +/// Assert that parsing succeeds +macro_rules! assert_parses { + ($src:expr) => { + match parse($src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse:\n{}\nError: {}", $src, e), + } + }; +} + +/// Assert that parsing fails +macro_rules! assert_parse_fails { + ($src:expr) => { + assert!( + parse($src).is_err(), + "Expected parse to fail but it succeeded:\n{}", + $src + ); + }; +} + +// ============================================================================= +// Empty and minimal programs +// ============================================================================= + +#[test] +fn test_empty_program() { + assert_parses!(""); +} + +#[test] +fn test_whitespace_only() { + assert_parses!(" \n\t\n "); +} + +#[test] +fn test_comments_only() { + assert_parses!("// line comment\n"); + assert_parses!("/* block comment */"); + assert_parses!("// comment 1\n// comment 2\n"); + assert_parses!("/* multi\nline\ncomment */"); +} + +// ============================================================================= +// Const declarations +// ============================================================================= + +#[test] +fn test_const_with_type() { + assert_parses!("x: u32 = 42;"); + assert_parses!("pi: f64 = 3.14159;"); + assert_parses!("flag: bool = true;"); +} + +#[test] +fn test_const_inferred_type() { + assert_parses!("x := 42;"); + assert_parses!("name := \"hello\";"); +} + +#[test] +fn test_const_pub() { + assert_parses!("pub API_VERSION: u32 = 1;"); +} + +#[test] +fn test_const_expressions() { + assert_parses!("sum := 1 + 2;"); + assert_parses!("product := 3 * 4;"); + assert_parses!("complex := (1 + 2) * 3;"); +} + +// ============================================================================= +// Var declarations +// ============================================================================= + +#[test] +fn test_var_with_type() { + assert_parses!("mut count: u32 = 0;"); + assert_parses!("mut name: []u8 = undefined;"); +} + +#[test] +fn test_var_undefined() { + assert_parses!("mut buffer: [1024]u8 = undefined;"); +} + +#[test] +fn test_var_pub() { + assert_parses!("pub mut global_state: i32 = 0;"); +} + +// ============================================================================= +// Function declarations +// ============================================================================= + +#[test] +fn test_fn_no_params_void() { + assert_parses!("fn main() -> unit {}"); +} + +#[test] +fn test_fn_with_params() { + assert_parses!("fn add(a: i32, b: i32) -> i32 { return a + b; }"); +} + +#[test] +fn test_fn_pub() { + assert_parses!("pub fn process() -> unit {}"); +} + +#[test] +fn test_fn_inline() { + assert_parses!("inline fn fast_add(a: i32, b: i32) -> i32 { return a + b; }"); +} + +#[test] +fn test_extern_fn_basic() { + // Basic extern function with C calling convention + assert_parses!(r#"extern "C" fn decode(data: [*]u8, len: usize) -> i32;"#); +} + +#[test] +fn test_extern_fn_no_params() { + // Extern function with no parameters + assert_parses!(r#"extern "C" fn get_version() -> u32;"#); +} + +#[test] +fn test_extern_fn_no_return() { + // Extern function with no return type (returns unit) + assert_parses!(r#"extern "C" fn init();"#); +} + +#[test] +fn test_extern_fn_pub() { + // Public extern function + assert_parses!(r#"pub extern "C" fn mwpm_decode(syndrome: [*]const u8, n: usize) -> i32;"#); +} + +#[test] +fn test_extern_fn_rust_abi() { + // Extern function with Rust calling convention + assert_parses!(r#"extern "Rust" fn pecos_decode(data: *const u8) -> DecoderResult;"#); +} + +#[test] +fn test_fn_comptime_param() { + assert_parses!("fn make_array(comptime T: type, comptime N: usize) -> unit {}"); +} + +#[test] +fn test_fn_with_body() { + assert_parses!( + r#" + fn factorial(n: u64) -> u64 { + if (n <= 1) { + return 1; + } + return n * factorial(n - 1); + } + "# + ); +} + +// ============================================================================= +// Struct declarations +// ============================================================================= + +#[test] +fn test_struct_empty() { + assert_parses!("Empty := struct {};"); +} + +#[test] +fn test_struct_with_fields() { + assert_parses!( + r#" + Point := struct { + x: f64, + y: f64, + }; + "# + ); +} + +#[test] +fn test_struct_with_defaults() { + assert_parses!( + r#" + Config := struct { + width: u32 = 800, + height: u32 = 600, + fullscreen: bool = false, + }; + "# + ); +} + +#[test] +fn test_struct_with_methods() { + assert_parses!( + r#" + Counter := struct { + value: u32, + + fn increment(&mut self) -> unit { + self.value = self.value + 1; + } + + fn get(&mut self) -> u32 { + return self.value; + } + }; + "# + ); +} + +#[test] +fn test_struct_pub() { + assert_parses!( + r#" + pub PublicStruct := struct { + data: u32, + }; + "# + ); +} + +#[test] +fn test_keywords_as_member_names() { + // Keywords can be used as struct field names + assert_parses!( + r#" + MyStruct := struct { + set: bool, + union: u32, + type: usize, + }; + "# + ); + + // Keywords can be used as method names + assert_parses!( + r#" + Container := struct { + data: u32, + + pub fn set(&mut self, value: u32) -> void { + self.data = value; + return unit; + } + + pub fn union(&mut self, other: *Self) -> void { + self.data = self.data + other.data; + return unit; + } + }; + "# + ); + + // Keywords can be accessed as field names + assert_parses!( + r#" + fn use_keywords(s: *MyStruct) -> void { + x := s.set; + y := s.union; + z := s.type; + return unit; + } + "# + ); + + // Keywords in range bounds (field access) + assert_parses!( + r#" + fn iterate(s: *Container) -> void { + for i in 0..s.set { + process(i); + } + } + "# + ); +} + +#[test] +fn test_block_attributes() { + // Block with single attribute + assert_parses!( + r#" + fn main() -> unit { + @attr(kind, "init") + { + h q[0]; + } + return unit; + } + "# + ); + + // Block with multiple attributes + assert_parses!( + r#" + fn main() -> unit { + @attrs({round: 0, kind: "syndrome"}) + { + cx (q[0], q[1]); + } + return unit; + } + "# + ); + + // Labeled block with attributes + assert_parses!( + r#" + fn main() -> unit { + @attr(priority, 1) + setup: { + pz q; + h q[0]; + } + return unit; + } + "# + ); + + // Block expression with attributes + assert_parses!( + r#" + fn compute() -> u32 { + result := @attr(optimized, true) compute_block: { + x := 1 + 2; + x * 3 + }; + return result; + } + "# + ); +} + +// ============================================================================= +// Enum declarations +// ============================================================================= + +#[test] +fn test_enum_simple() { + assert_parses!( + r#" + Color := enum { + Red, + Green, + Blue, + }; + "# + ); +} + +#[test] +fn test_enum_with_values() { + assert_parses!( + r#" + Status := enum(u8) { + Ok = 0, + Error = 1, + Pending = 2, + }; + "# + ); +} + +#[test] +fn test_union_tagged() { + // Auto-tagged union with union(enum) + assert_parses!( + r#" + Value := union(enum) { + Int: i32, + Float: f64, + Bool: bool, + None, + }; + "# + ); +} + +#[test] +fn test_union_untagged() { + // Untagged union + assert_parses!( + r#" + RawValue := union { + Int: i32, + Float: f64, + }; + "# + ); +} + +#[test] +fn test_union_external_tag() { + // Externally-tagged union + assert_parses!( + r#" + MyTag := enum { A, B, C }; + MyUnion := union(MyTag) { + A: u32, + B: f32, + C, + }; + "# + ); +} + +// ============================================================================= +// Control flow statements +// ============================================================================= + +#[test] +fn test_if_simple() { + assert_parses!( + r#" + fn run() -> unit { + if (x > 0) { + do_something(); + } + } + "# + ); +} + +#[test] +fn test_if_else() { + assert_parses!( + r#" + fn run() -> unit { + if (x > 0) { + positive(); + } else { + non_positive(); + } + } + "# + ); +} + +#[test] +fn test_if_else_if() { + assert_parses!( + r#" + fn run() -> unit { + if (x > 0) { + positive(); + } else if (x < 0) { + negative(); + } else { + zero(); + } + } + "# + ); +} + +#[test] +fn test_for_loop_range() { + assert_parses!( + r#" + fn run() -> unit { + for i in 0..10 { + process(i); + } + } + "# + ); +} + +#[test] +fn test_for_loop_inline() { + assert_parses!( + r#" + fn run() -> unit { + inline for i in 0..4 { + unrolled(i); + } + } + "# + ); +} + +#[test] +fn test_for_loop_runtime_bound() { + // Runtime-bounded for loop (bound is a runtime value) + assert_parses!( + r#" + fn process(n: usize) -> unit { + for i in 0..n { + do_work(i); + } + } + "# + ); + + // Runtime bound with method call on self + assert_parses!( + r#" + fn iterate(&mut self) -> unit { + for i in 0..self.len { + process(self.items[i]); + } + } + "# + ); + + // Mixed: comptime capacity bound with early break on runtime value + assert_parses!( + r#" + Container := fn(comptime capacity: usize) -> type { + struct { + items: [capacity]u32 = undefined, + len: usize = 0, + + fn search(&mut self, target: u32) -> ?usize { + for i in 0..capacity { + if (i >= self.len) { + break; + } + if (self.items[i] == target) { + return i; + } + } + return none; + } + } + }; + "# + ); +} + +#[test] +fn test_switch() { + assert_parses!( + r#" + fn handle(x: u32) -> unit { + switch (x) { + 0 => handle_zero(), + 1 => handle_one(), + else => handle_other(), + } + } + "# + ); +} + +#[test] +fn test_return() { + assert_parses!("fn run() -> u32 { return 42; }"); + assert_parses!("fn run() -> unit { return; }"); +} + +#[test] +fn test_break_continue() { + assert_parses!( + r#" + fn run() -> unit { + for i in 0..100 { + if (done) { + break; + } + if (skip) { + continue; + } + } + } + "# + ); +} + +#[test] +fn test_labeled_break() { + assert_parses!( + r#" + fn run() -> unit { + outer: for _ in 0..100 { + for _ in 0..100 { + break :outer; + } + } + } + "# + ); +} + +#[test] +fn test_defer() { + assert_parses!( + r#" + fn run() -> unit { + defer cleanup(); + do_work(); + } + "# + ); +} + +#[test] +fn test_errdefer() { + // Basic errdefer without capture + assert_parses!( + r#" + fn run() -> unit { + errdefer cleanup(); + do_work(); + } + "# + ); + + // errdefer with capture + assert_parses!( + r#" + fn run() -> unit { + errdefer |err| { + log_error(err); + } + do_work(); + } + "# + ); + + // errdefer with block body + assert_parses!( + r#" + fn run() -> unit { + errdefer { + cleanup(); + notify(); + } + do_work(); + } + "# + ); +} + +// ============================================================================= +// Expressions +// ============================================================================= + +#[test] +fn test_literals() { + assert_parses!("a := 42;"); + assert_parses!("b := 3.14;"); + assert_parses!("c := true;"); + assert_parses!("d := false;"); + assert_parses!("e := none;"); + assert_parses!(r#"f := "hello";"#); + assert_parses!("g := 'x';"); +} + +#[test] +fn test_number_formats() { + assert_parses!("dec := 42;"); + assert_parses!("hex := 0xFF;"); + assert_parses!("bin := 0b1010;"); + assert_parses!("oct := 0o77;"); + assert_parses!("with_underscore := 1_000_000;"); + assert_parses!("float := 3.14159;"); + assert_parses!("exp := 1e10;"); +} + +#[test] +fn test_binary_operators() { + assert_parses!("a := 1 + 2;"); + assert_parses!("b := 3 - 1;"); + assert_parses!("c := 2 * 3;"); + assert_parses!("d := 10 / 2;"); + assert_parses!("e := 10 % 3;"); + assert_parses!("f := a == b;"); + assert_parses!("g := a != b;"); + assert_parses!("h := a < b;"); + assert_parses!("i := a <= b;"); + assert_parses!("j := a > b;"); + assert_parses!("k := a >= b;"); + assert_parses!("l := a and b;"); + assert_parses!("m := a or b;"); + assert_parses!("n := a & b;"); + assert_parses!("o := a | b;"); + assert_parses!("p := a ^ b;"); + assert_parses!("q := a << 2;"); + assert_parses!("r := a >> 2;"); +} + +#[test] +fn test_unary_operators() { + assert_parses!("a := -x;"); + assert_parses!("b := !flag;"); + assert_parses!("c := ~bits;"); + assert_parses!("d := &value;"); + assert_parses!("e := *ptr;"); +} + +#[test] +fn test_operator_precedence() { + assert_parses!("a := 1 + 2 * 3;"); + assert_parses!("b := (1 + 2) * 3;"); + assert_parses!("c := a and b or c;"); + assert_parses!("d := a == b and c != d;"); +} + +#[test] +fn test_function_calls() { + assert_parses!("a := foo();"); + assert_parses!("b := bar(1);"); + assert_parses!("c := baz(1, 2, 3);"); + assert_parses!("d := nested(inner(x));"); +} + +#[test] +fn test_field_access() { + assert_parses!("a := obj.field;"); + assert_parses!("b := obj.nested.field;"); + assert_parses!("c := get_obj().field;"); +} + +#[test] +fn test_index_access() { + assert_parses!("a := arr[0];"); + assert_parses!("b := arr[i];"); + assert_parses!("c := matrix[i][j];"); +} + +#[test] +fn test_if_expression() { + // If expressions require parentheses around condition to disambiguate from the block + // Blocks support trailing expressions like Rust + assert_parses!("max := if (a > b) { a } else { b };"); +} + +#[test] +fn test_break_with_expression() { + // Simple break with expression + assert_parses!("fn run() -> unit { break 42; }"); + // Break with label and expression + assert_parses!("fn run() -> unit { break :lbl 42; }"); +} + +#[test] +fn test_block_expression() { + assert_parses!( + r#" + fn run() -> u32 { + result := blk: { + temp := compute(); + break :blk temp * 2; + }; + return result; + } + "# + ); +} + +#[test] +fn test_builtin_calls() { + assert_parses!(r#"std := @import("std");"#); + // Self is now a keyword (like Rust), no need for @This() alias + assert_parses!("x := Self;"); // Self can be used as a type value + assert_parses!("size := @sizeOf(u32);"); +} + +#[test] +fn test_struct_init() { + // Rust-style struct init: `Type { field: value }` + assert_parses!("p := Point { x: 1.0, y: 2.0 };"); + assert_parses!("empty := .{};"); + assert_parses!("anon := .{ a: 1, b: 2 };"); + // Shorthand when variable name matches field name + assert_parses!("x := 1; y := 2; p := Point { x, y };"); +} + +#[test] +fn test_array_init() { + assert_parses!("arr := .{ 1, 2, 3 };"); + assert_parses!("typed := [3]u32{ 1, 2, 3 };"); +} + +// ============================================================================= +// Types +// ============================================================================= + +#[test] +fn test_primitive_types() { + assert_parses!("a: u8 = 0;"); + assert_parses!("b: u16 = 0;"); + assert_parses!("c: u32 = 0;"); + assert_parses!("d: u64 = 0;"); + assert_parses!("e: i8 = 0;"); + assert_parses!("f: i16 = 0;"); + assert_parses!("g: i32 = 0;"); + assert_parses!("h: i64 = 0;"); + assert_parses!("i: f32 = 0.0;"); + assert_parses!("j: f64 = 0.0;"); + assert_parses!("k: bool = true;"); + assert_parses!("l: usize = 0;"); +} + +#[test] +fn test_array_types() { + assert_parses!("a: [10]u8 = undefined;"); + assert_parses!("b: []u8 = undefined;"); + assert_parses!("c: [_]u8 = undefined;"); +} + +#[test] +fn test_array_size_expressions() { + // Simple identifier + assert_parses!("a: [N]u8 = undefined;"); + // Simple literal + assert_parses!("b: [64]u8 = undefined;"); + // Addition expression + assert_parses!("c: [N + 1]u8 = undefined;"); + // Subtraction expression + assert_parses!("d: [N - 1]u8 = undefined;"); + // Multiplication expression + assert_parses!("e: [N * 2]u8 = undefined;"); + // Division expression + assert_parses!("f: [N / 2]u8 = undefined;"); + // Complex expression + assert_parses!("g: [N + M + 1]u8 = undefined;"); + // Parenthesized expression + assert_parses!("h: [(N + 1) * 2]u8 = undefined;"); + // In function returning comptime type + assert_parses!( + r#" + pub MyArray := fn(comptime N: usize) -> type { + struct { + data: [N + 1]u8 = undefined, + } + }; + "# + ); +} + +#[test] +fn test_pointer_types() { + assert_parses!("a: *u32 = undefined;"); + // Note: When type is explicit, use = not := + assert_parses!("b: *u32 = undefined;"); + assert_parses!("c: [*]u8 = undefined;"); +} + +#[test] +fn test_sentinel_terminated_pointers() { + // Null-terminated string (sentinel = 0) + assert_parses!("s: [*:0]u8 = undefined;"); + // Sentinel with different value + assert_parses!("t: [*:255]u8 = undefined;"); + // Sentinel with const + assert_parses!("u: [*:0]const u8 = undefined;"); + // Sentinel with expression + assert_parses!("v: [*:0xFF]u8 = undefined;"); +} + +#[test] +fn test_sentinel_terminated_arrays() { + // Array with sentinel value + assert_parses!("a: [10:0]u8 = undefined;"); + // Array with different sentinel + assert_parses!("b: [5:255]u8 = undefined;"); + // Slice with sentinel (no size, just sentinel) + assert_parses!("c: [:0]u8 = undefined;"); +} + +#[test] +fn test_optional_types() { + assert_parses!("a: ?u32 = none;"); + assert_parses!("b: ?*Node = none;"); +} + +#[test] +fn test_error_union_types() { + assert_parses!("fn run() -> Error!u32 { return 42; }"); +} + +#[test] +fn test_error_value() { + // error.Name syntax for error literals + // Use := when type is inferred (walrus operator) + assert_parses!("e := error.OutOfMemory;"); + assert_parses!("e := error.InvalidArgument;"); + assert_parses!("e := error.FileNotFound;"); +} + +#[test] +fn test_error_set_definition() { + // Error set definition: MyError := error { ... } + assert_parses!("MyError := error { OutOfMemory };"); + assert_parses!("FileError := error { NotFound, AccessDenied, Busy };"); + assert_parses!( + r#" + DivisionError := error { + DivisionByZero, + Overflow, + Underflow, + }; + "# + ); + // Public error set + assert_parses!("pub IoError := error { ReadError, WriteError };"); +} + +#[test] +fn test_fault_set_definition() { + // Fault set definition: MyFault := fault { ... } + assert_parses!("QuantumFault := fault { Leakage };"); + assert_parses!("GateFault := fault { Leakage, QubitLoss, GateFailure };"); + assert_parses!( + r#" + QuantumFault := fault { + Leakage, + QubitLoss, + GateFailure, + }; + "# + ); + // Fault with associated data (using type references) + assert_parses!( + r#" + QuantumFault := fault { + Leakage: LeakageInfo, + QubitLoss: QubitLossInfo, + }; + "# + ); + // Public fault set + assert_parses!("pub QuantumFault := fault { Leakage, QubitLoss };"); +} + +#[test] +fn test_set_literal() { + // Set literals with set keyword (consistent with struct, enum, union) + assert_parses!("primes := set { 2, 3, 5, 7 };"); + assert_parses!("empty := set {};"); + assert_parses!("single := set { 42 };"); + // Set with explicit type + assert_parses!("nums: Set(i64) = set { 1, 2, 3 };"); +} + +#[test] +fn test_batch_apply() { + // Batch gate apply: h { q[0], q[1] } - set semantics (order doesn't matter) + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + h { q[0], q[1], q[2] }; + } + "# + ); + // Parameterized gate batch + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + rz(pi/4) { q[0], q[1] }; + } + "# + ); + // Two-qubit gate batch with pairs + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + cx { (q[0], q[1]), (q[2], q[3]) }; + } + "# + ); +} + +#[test] +fn test_measure_syntax() { + // New measurement syntax: mz(T) targets + // Inline array (ordered results) + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + results := mz(u1) [q[0], q[1], q[2]]; + } + "# + ); + // Single qubit measurement + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(2); + r := mz(u1) q[0]; + } + "# + ); + // Different result types + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(2); + r8 := mz(u8) [q[0]]; + r64 := mz(u64) [q[0], q[1]]; + } + "# + ); +} + +#[test] +fn test_try_expression() { + // try expr - error propagation + assert_parses!( + r#" + fn process() -> Error!u32 { + result := try doSomething(); + return result; + } + "# + ); +} + +#[test] +fn test_catch_expression() { + // expr catch handler + assert_parses!( + r#" + fn safe_divide(a: u32, b: u32) -> u32 { + return divide(a, b) catch 0; + } + "# + ); + // expr catch |err| handler + assert_parses!( + r#" + fn safe_op() -> u32 { + return risky() catch |err| handleError(err); + } + "# + ); +} + +#[test] +fn test_error_unwrap() { + // .! postfix operator for error unwrap + assert_parses!( + r#" + fn unwrap_result(r: Error!u32) -> u32 { + return r.!; + } + "# + ); +} + +#[test] +fn test_try_block_collect() { + // try { } - collect quantum faults (QEC pattern) + assert_parses!( + r#" + fn qec_round(q: []qubit) try -> []QuantumFault!void { + h q[0]; + cx (q[0], q[1]); + } + "# + ); + // try { } as statement (no semicolon after block) + assert_parses!( + r#" + fn circuit() -> unit { + mut q := qalloc(4); + try { + h q[0]; + cx (q[0], q[1]); + } + } + "# + ); + // try { } with catch + assert_parses!( + r#" + fn circuit() -> unit { + mut q := qalloc(4); + try { + h q[0]; + } catch |err| { + log(err); + } + } + "# + ); +} + +#[test] +fn test_try_block_propagate() { + // try! { } - stop on first fault/error (traditional) + assert_parses!( + r#" + fn strict_circuit(q: []qubit) try! -> QuantumFault!void { + h q[0]; + cx (q[0], q[1]); + } + "# + ); + // try! { } as statement with catch (no semicolon after block) + assert_parses!( + r#" + fn circuit() -> unit { + mut q := qalloc(4); + try! { + h q[0]; + cx (q[0], q[1]); + } catch |err| { + abort(); + } + } + "# + ); +} + +#[test] +fn test_try_block_expression() { + // try { } as expression (for assignment) + assert_parses!( + r#" + fn collect_errors() -> unit { + mut q := qalloc(4); + errors := try { + h q[0]; + cx (q[0], q[1]); + }; + } + "# + ); + // try! { } as expression with catch providing default + assert_parses!( + r#" + fn safe_measure() -> u1 { + mut q := qalloc(1); + result := try! { + h q[0]; + mz(u1) q[0] + } catch |err| { 0 }; + return result; + } + "# + ); +} + +#[test] +fn test_function_types() { + assert_parses!("callback: fn(u32) -> unit = undefined;"); + assert_parses!("binary_fn: fn(i32, i32) -> i32 = undefined;"); +} + +#[test] +fn test_named_types() { + assert_parses!("a: MyStruct = undefined;"); + assert_parses!("b: std.mem.Allocator = undefined;"); +} + +// ============================================================================= +// Quantum-specific syntax +// ============================================================================= + +#[test] +fn test_quantum_types() { + assert_parses!("q: qubit = undefined;"); + assert_parses!("b: bit = undefined;"); + assert_parses!("mut alloc: Alloc = undefined;"); +} + +#[test] +fn test_quantum_allocator() { + assert_parses!( + r#" + fn main() -> unit { + mut base := qalloc(100); + mut data := base.child(9); + mut ancilla := base.child(8); + } + "# + ); +} + +#[test] +fn test_quantum_prepare() { + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + pz {q[0], q[1], q[2]}; + } + "# + ); +} + +#[test] +fn test_quantum_gates() { + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + H(q[0]); + X(q[1]); + CX(q[0], q[1]); + RZ(q[0], 0.5); + } + "# + ); +} + +#[test] +fn test_swap_and_toffoli_gates() { + // SWAP gate + assert_parses!( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + swap (q[0], q[1]); + } + "# + ); + + // iSWAP gate + assert_parses!( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + iswap (q[0], q[1]); + } + "# + ); + + // CCX (Toffoli) gate + assert_parses!( + r#" + fn main() -> unit { + q := qalloc(3); + pz q; + ccx (q[0], q[1], q[2]); + } + "# + ); +} + +#[test] +fn test_quantum_measure() { + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(2); + pz q; + H(q[0]); + CX(q[0], q[1]); + result := measure(q[0]); + all_results := measure(q); + } + "# + ); +} + +#[test] +fn test_quantum_conditional() { + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(2); + pz q; + H(q[0]); + m := measure(q[0]); + if (m) { + X(q[1]); + } + } + "# + ); +} + +#[test] +fn test_quantum_bell_state() { + assert_parses!( + r#" + pub fn bell_state() -> unit { + mut base := qalloc(2); + mut q := base.child(2); + + pz q; + + H(q[0]); + CX(q[0], q[1]); + + results := measure(q); + } + "# + ); +} + +#[test] +fn test_quantum_teleportation() { + assert_parses!( + r#" + pub fn teleport() -> unit { + mut base := qalloc(10); + mut state := base.child(1); + mut epr := base.child(2); + + pz state; + pz epr; + + H(epr[0]); + CX(epr[0], epr[1]); + + CX(state[0], epr[0]); + H(state[0]); + + m1 := measure(state[0]); + m2 := measure(epr[0]); + + if (m2) { + X(epr[1]); + } + if (m1) { + Z(epr[1]); + } + } + "# + ); +} + +// ============================================================================= +// Comptime features +// ============================================================================= + +#[test] +fn test_comptime_expression() { + assert_parses!("size := comptime 4 * 8;"); +} + +#[test] +fn test_comptime_block() { + assert_parses!( + r#" + value := comptime { + mut result: u32 = 0; + result = 42; + }; + "# + ); +} + +#[test] +fn test_comptime_type_function() { + assert_parses!( + r#" + fn make_array(comptime T: type, comptime N: usize) -> type { + return [N]T; + } + "# + ); +} + +// ============================================================================= +// Test declarations +// ============================================================================= + +#[test] +fn test_test_decl() { + assert_parses!( + r#" + test "basic addition" { + result := 1 + 1; + } + "# + ); +} + +// ============================================================================= +// Documentation comments +// ============================================================================= + +#[test] +fn test_doc_comments() { + assert_parses!( + r#" + /// This is a documented function. + /// It does important things. + pub fn documented() -> unit {} + "# + ); +} + +// ============================================================================= +// Complex programs +// ============================================================================= + +#[test] +fn test_surface_code_skeleton() { + assert_parses!( + r#" + pub fn surface_code(comptime distance: u32) -> type { + num_data := distance * distance; + num_ancilla := (distance - 1) * (distance - 1) * 2; + + return struct { + data: Alloc, + ancilla: Alloc, + + // Self is implicitly available in struct methods (Rust-style) + + pub fn init(base: *Alloc) -> Self { + return Self { + data: base.child(num_data), + ancilla: base.child(num_ancilla), + }; + } + + pub fn syndrome_round(&mut self) -> unit { + pz self.ancilla; + + inline for i in 0..num_ancilla { + h(self.ancilla[i]); + } + + syndrome := measure(self.ancilla); + } + }; + } + "# + ); +} + +// ============================================================================= +// Error cases (should fail to parse) +// ============================================================================= + +#[test] +fn test_missing_semicolon() { + assert_parse_fails!("x := 42"); +} + +#[test] +fn test_invalid_identifier() { + assert_parse_fails!("const 123abc = 1;"); +} + +#[test] +fn test_unbalanced_braces() { + assert_parse_fails!("fn run() -> unit {"); + assert_parse_fails!("fn run() -> unit }"); +} + +#[test] +fn test_unbalanced_parens() { + assert_parse_fails!("x := (1 + 2;"); +} + +// ============================================================================= +// Zig-style syntax should fail (we use Rust-style) +// ============================================================================= + +#[test] +fn test_zig_style_struct_init_fails() { + // Old Zig-style: `.field = value` - should fail, use `field: value` + assert_parse_fails!("p := Point { .x = 1, .y = 2 };"); + assert_parse_fails!("anon := .{ .a = 1, .b = 2 };"); +} + +#[test] +fn test_zig_style_no_space_before_brace_ok() { + // `Type{ ... }` without space is still valid + assert_parses!("p := Point{ x: 1, y: 2 };"); +} + +#[test] +fn test_rust_style_struct_init_ok() { + // Rust-style: `field: value` - should work + assert_parses!("p := Point { x: 1, y: 2 };"); + assert_parses!("anon := .{ a: 1, b: 2 };"); + // Shorthand + assert_parses!("x := 1; p := Point { x };"); +} + +#[test] +fn test_zig_style_this_parses_as_builtin() { + // @This() still parses (as builtin call) but Self is preferred + // Semantic analysis could reject it, but parsing accepts it + assert_parses!("T := @This();"); +} + +#[test] +fn test_zig_style_null_parses_as_identifier() { + // null parses as identifier, but will fail semantic analysis + // (undefined symbol). Use none instead. + assert_parses!("x := null;"); +} + +#[test] +fn test_explicit_self_type_still_valid() { + // Explicit self: *Type is still valid (for advanced cases) + // But &mut self is the idiomatic style + assert_parses!("fn foo(self: *Foo) -> unit {}"); +} + +#[test] +fn test_rust_style_self_ok() { + // Rust-style Self and &mut self should work + assert_parses!("fn foo() -> Self { return Self; }"); + assert_parses!("Foo := struct { fn bar(&mut self) -> unit { return unit; } };"); + assert_parses!("Foo := struct { fn baz(&self) -> unit { return unit; } };"); +} + +#[test] +fn test_rust_style_none_ok() { + // none instead of null + assert_parses!("x := none;"); + assert_parses!("opt: ?u32 = none;"); +} + +#[test] +fn test_python_style_fstring_ok() { + // Python-style f-strings + assert_parses!(r#"msg := f"Hello";"#); + assert_parses!(r#"name := "World"; msg := f"Hello {name}";"#); + assert_parses!(r#"x := 42; msg := f"value = {x}";"#); + assert_parses!(r#"a := 1; b := 2; msg := f"sum = {a + b}";"#); + assert_parses!(r#"msg := f"escaped \{ brace \}";"#); +} + +#[test] +fn test_python_style_fstring_format_spec_ok() { + // F-strings with format specifiers (Python-style) + assert_parses!(r#"x := 3.14159; msg := f"pi = {x:.2f}";"#); + assert_parses!(r#"n := 42; msg := f"padded = {n:08d}";"#); + assert_parses!(r#"name := "Alice"; msg := f"name = {name:>10}";"#); + assert_parses!(r#"val := 255; msg := f"hex = {val:x}";"#); + // Multiple format specifiers in one string + assert_parses!(r#"x := 1.0; y := 2.0; msg := f"({x:.1f}, {y:.1f})";"#); +} + +#[test] +fn test_python_style_raw_string_ok() { + // Python-style raw strings (no escape processing) + assert_parses!(r#"path := r"C:\Users\test";"#); + assert_parses!(r#"regex := r"\d+\.\d+";"#); + assert_parses!(r#"s := r"no escapes: \n \t \\";"#); +} + +#[test] +fn test_python_style_in_operator_ok() { + // Python-style in/not in operators + assert_parses!("items := set { 1, 2, 3 }; x := 5 in items;"); + assert_parses!("items := set { 1, 2, 3 }; x := 5 not in items;"); +} + +#[test] +fn test_python_style_multiline_string_ok() { + // Python-style triple-quoted multi-line strings + assert_parses!(r#"text := """hello""";"#); + assert_parses!( + r#"text := """line 1 +line 2 +line 3""";"# + ); + assert_parses!( + r#"sql := """ +SELECT * +FROM users +WHERE active = true +""";"# + ); + // Escape sequences still work inside multi-line strings + assert_parses!(r#"text := """tab:\there""";"#); +} + +// ============================================================================= +// Standard Library Tests +// ============================================================================= + +#[test] +fn test_std_bits_parses() { + let src = include_str!("../std/bits.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/bits.zlup:\n{}", e), + } +} + +#[test] +fn test_std_containers_parses() { + let src = include_str!("../std/containers.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/containers.zlup:\n{}", e), + } +} + +#[test] +fn test_std_qec_parses() { + let src = include_str!("../std/qec.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/qec.zlup:\n{}", e), + } +} + +#[test] +fn test_std_math_parses() { + let src = include_str!("../std/math.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/math.zlup:\n{}", e), + } +} + +#[test] +fn test_std_main_parses() { + let src = include_str!("../std/std.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/std.zlup:\n{}", e), + } +} + +#[test] +fn test_std_f64_parses() { + let src = include_str!("../std/f64.zlp"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/f64.zlp:\n{}", e), + } +} + +#[test] +fn test_std_a64_parses() { + let src = include_str!("../std/a64.zlp"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/a64.zlp:\n{}", e), + } +} + +// ============================================================================= +// Deprecated gate names rejected at grammar level +// ============================================================================= + +#[test] +fn test_deprecated_s_gate_rejected() { + // "s" is no longer a valid gate keyword — use "sz" instead + assert_parse_fails!("fn main() -> unit { s q[0]; }"); +} + +#[test] +fn test_deprecated_sdg_gate_rejected() { + // "sdg" is no longer a valid gate keyword — use "szdg" instead + assert_parse_fails!("fn main() -> unit { sdg q[0]; }"); +} + +// ============================================================================= +// Gate name suggestion helpers +// ============================================================================= + +#[test] +fn test_suggest_gate_name_deprecated() { + use crate::parser::suggest_gate_name; + assert_eq!(suggest_gate_name("s"), Some("sz")); + assert_eq!(suggest_gate_name("sdg"), Some("szdg")); +} + +#[test] +fn test_suggest_gate_name_typo() { + use crate::parser::suggest_gate_name; + // Close misspellings should suggest the correct gate + assert_eq!(suggest_gate_name("cx"), Some("cx")); // exact match (dist 0) + assert_eq!(suggest_gate_name("hh"), Some("h")); // edit distance 1 + assert_eq!(suggest_gate_name("swp"), Some("swap")); // edit distance 1 +} + +#[test] +fn test_suggest_gate_name_no_match() { + use crate::parser::suggest_gate_name; + assert_eq!(suggest_gate_name("foobar"), None); +} + +#[test] +fn test_edit_distance() { + use crate::parser::edit_distance; + assert_eq!(edit_distance("", ""), 0); + assert_eq!(edit_distance("abc", "abc"), 0); + assert_eq!(edit_distance("abc", "abx"), 1); + assert_eq!(edit_distance("abc", "ab"), 1); + assert_eq!(edit_distance("abc", "abcd"), 1); + assert_eq!(edit_distance("kitten", "sitting"), 3); +} + +// ============================================================================= +// Custom gate declarations +// ============================================================================= + +#[test] +fn test_parse_declare_gate() { + assert_parses!("declare gate my_gate(theta)(q);"); +} + +#[test] +fn test_parse_declare_gate_no_params() { + assert_parses!("declare gate my_x()(q);"); +} + +#[test] +fn test_parse_declare_gate_multiple_qubits() { + assert_parses!("declare gate cnot()(control, target);"); +} + +#[test] +fn test_parse_declare_gate_multiple_params() { + assert_parses!("declare gate u3(theta, phi, lambda)(q);"); +} + +#[test] +fn test_parse_declare_gate_pub() { + assert_parses!("pub declare gate rz(angle)(q);"); +} + +#[test] +fn test_parse_composite_gate() { + assert_parses!( + r#" + gate my_h()(q) { + h q; + } + "# + ); +} + +#[test] +fn test_parse_composite_gate_with_params() { + assert_parses!( + r#" + gate rx(theta)(q) { + h q; + } + "# + ); +} + +#[test] +fn test_parse_composite_gate_multi_qubit() { + assert_parses!( + r#" + gate bell()(q0, q1) { + h q0; + cx (q0, q1); + } + "# + ); +} + +#[test] +fn test_parse_pub_composite_gate() { + assert_parses!( + r#" + pub gate swap()(a, b) { + cx (a, b); + cx (b, a); + cx (a, b); + } + "# + ); +} + +#[test] +fn test_parse_declare_gate_and_fn_together() { + assert_parses!( + r#" + declare gate custom_rx(theta)(q); + + pub fn apply_custom(q: qubit) -> unit { + h q; + return; + } + "# + ); +} + +#[test] +fn test_parse_composite_gate_with_typed_param() { + assert_parses!("gate rz(theta: a64)(q) { h q; }"); +} diff --git a/exp/zlup/src/zluppy.pest b/exp/zlup/src/zluppy.pest new file mode 100644 index 000000000..0d2aa186b --- /dev/null +++ b/exp/zlup/src/zluppy.pest @@ -0,0 +1,990 @@ +// Zluppy Grammar +// A Zig-inspired quantum programming language for SLR +// +// Design principles (NASA Power of 10 influenced): +// 1. Simple, predictable control flow +// 2. Fixed bounds on loops where possible +// 3. Allocator-based resource management (no dynamic alloc after init) +// 4. Explicit over implicit +// 5. Comptime for metaprogramming + +// ============================================================================= +// Whitespace and Comments +// ============================================================================= + +// Whitespace includes newlines for simpler grammar +WHITESPACE = _{ " " | "\t" | "\r" | "\n" } +line_comment = _{ "//" ~ (!"\n" ~ ANY)* } +block_comment = _{ "/*" ~ (!"*/" ~ ANY)* ~ "*/" } +COMMENT = _{ line_comment | block_comment } + +// Explicit whitespace rule for places needing explicit control +ws = _{ (WHITESPACE | COMMENT)* } + +// Keyword operators with word boundary checks for binary expressions +and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") } +or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") } +orelse_kw = @{ "orelse" ~ !(ASCII_ALPHANUMERIC | "_") } +catch_kw = @{ "catch" ~ !(ASCII_ALPHANUMERIC | "_") } + +// Keywords that need to be captured +pub_keyword = { "pub" } +inline_keyword = { "inline" } +packed_keyword = { "packed" } + +// ============================================================================= +// Attributes (Metadata) +// ============================================================================= + +// Attributes provide metadata for ticks, gates, and other constructs +// @attr(key, value) - single attribute +// @attrs({key: value, ...}) - multiple attributes +// +// Examples: +// @attr(round, 0) +// @attr(kind, "syndrome") +// @attrs({round: 0, kind: "syndrome", error_rate: 0.001}) + +// Single attribute: @attr(key, value) +attribute = { "@attr" ~ ws ~ "(" ~ ws ~ identifier ~ ws ~ "," ~ ws ~ attr_value ~ ws ~ ")" } + +// Multiple attributes: @attrs({key: value, ...}) +attrs_block = { "@attrs" ~ ws ~ "(" ~ ws ~ "{" ~ ws ~ attrs_entries? ~ ws ~ "}" ~ ws ~ ")" } +attrs_entries = { attrs_entry ~ (ws ~ "," ~ ws ~ attrs_entry)* ~ ","? } +attrs_entry = { identifier ~ ws ~ ":" ~ ws ~ attr_value } + +attr_value = { string_literal | number_literal | bool_literal | identifier } +attribute_list = { (attribute | attrs_block) ~ (ws ~ (attribute | attrs_block))* } + +// ============================================================================= +// Program Structure +// ============================================================================= + +program = { SOI ~ ws ~ top_level_decl* ~ ws ~ EOI } + +top_level_decl = { + // error_set_decl and fault_set_decl must come before binding_decl + // because "Name := error { ... }" and "Name := fault { ... }" would + // otherwise match binding_decl (with struct init expression) + error_set_decl | + fault_set_decl | + declare_gate_decl | + gate_decl | + binding_decl | + extern_fn_decl | + fn_decl | + struct_decl | + enum_decl | + union_decl | + test_decl +} + +// ============================================================================= +// Declarations +// ============================================================================= + +// Binding declaration (Pascal/Go style with Rust mutability) +// Binding declaration syntax: +// x := value; -- immutable, type inferred +// x: T = value; -- immutable, type explicit +// mut x := value; -- mutable, type inferred +// mut x: T = value; -- mutable, type explicit +binding_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + (mut_keyword ~ ws)? ~ identifier ~ ws ~ ":" ~ ws ~ (type_expr ~ ws)? ~ "=" ~ ws ~ + (expr | undefined_literal) ~ ws ~ + ";" +} + +mut_keyword = { "mut" } + +// Function declaration +// try/try! modifiers indicate error handling mode for the function body +fn_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + inline_keyword? ~ ws ~ + "fn" ~ ws ~ member_name ~ ws ~ + "(" ~ ws ~ param_list? ~ ws ~ ")" ~ ws ~ + fn_error_mode? ~ ws ~ + return_type? ~ ws ~ + block +} + +// Function error mode: try (collect all) or try! (stop on first) +// fn foo() try -> []QuantumError!void { } +// fn foo() try! -> QuantumError!void { } +fn_error_mode = { try_bang_keyword | try_keyword } +try_keyword = { "try" ~ !("!") } +try_bang_keyword = { "try!" } + +// Comptime function (returns a type) +// pub fn make_code(comptime distance: u32) -> type { ... } +param_list = { param ~ (ws ~ "," ~ ws ~ param)* } +// Regular param or Rust-style self receiver +param = { self_param | regular_param } +// Rust-style self receivers: &self, &mut self, self +self_param = { "&" ~ ws ~ "mut"? ~ ws ~ "self" ~ !(ASCII_ALPHANUMERIC | "_") } +regular_param = { comptime_modifier? ~ ws ~ identifier ~ ws ~ ":" ~ ws ~ type_expr } +// Comptime modifier for parameters +comptime_modifier = { "comptime" } + +return_type = { "->" ~ ws ~ (type_expr | "!" ~ type_expr | "?" ~ type_expr) } + +// External function declaration (FFI) +// @link("libdecoder") extern "C" fn decode(data: [*]u8, len: usize) -> i32; +extern_fn_decl = { + doc_comment* ~ + link_attr? ~ ws ~ + pub_keyword? ~ ws ~ + "extern" ~ ws ~ string_literal ~ ws ~ + "fn" ~ ws ~ identifier ~ ws ~ + "(" ~ ws ~ param_list? ~ ws ~ ")" ~ ws ~ + return_type? ~ ws ~ + ";" +} + +// Library linkage attribute for extern functions +link_attr = { "@link" ~ ws ~ "(" ~ ws ~ string_literal ~ ws ~ ")" } + +// Struct declaration +struct_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + (packed_keyword ~ ws)? ~ "struct" ~ ws ~ "{" ~ ws ~ + struct_body ~ ws ~ + "}" ~ ws ~ ";" +} + +struct_body = { (ws ~ (binding_decl | fn_decl | struct_field))* ~ ws } +struct_field = { + doc_comment* ~ + member_name ~ ws ~ ":" ~ ws ~ type_expr ~ ws ~ + ("=" ~ ws ~ expr ~ ws)? ~ ","? +} + +// Enum declaration +enum_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + "enum" ~ ws ~ ("(" ~ ws ~ type_expr ~ ws ~ ")")? ~ ws ~ "{" ~ ws ~ + enum_body ~ ws ~ + "}" ~ ws ~ ";" +} + +enum_body = { enum_variant ~ (ws ~ "," ~ ws ~ enum_variant)* ~ ws ~ ","? } +enum_variant = { identifier ~ (ws ~ "=" ~ ws ~ expr)? } + +// Union declaration (tagged union / sum type) +// Value := union(enum) { Int: i32, Float: f64, None } -- auto-tagged +// Value := union(MyEnum) { Int: i32, Float: f64 } -- externally tagged +// Value := union { Int: i32, Float: f64 } -- untagged +union_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + "union" ~ ws ~ union_tag? ~ ws ~ "{" ~ ws ~ + union_body ~ ws ~ + "}" ~ ws ~ ";" +} + +union_tag = { "(" ~ ws ~ ("enum" | type_expr) ~ ws ~ ")" } +union_body = { union_field ~ (ws ~ "," ~ ws ~ union_field)* ~ ws ~ ","? } +union_field = { identifier ~ (ws ~ ":" ~ ws ~ type_expr)? } + +// Error set declaration - classical/logical errors that crash if unhandled +// DecodeError := error { SyndromeAmbiguous, WeightTooHigh }; +error_set_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + "error" ~ ws ~ "{" ~ ws ~ + error_set_body ~ ws ~ + "}" ~ ws ~ ";" +} + +// Fault set declaration - quantum/physical faults, collected in try blocks +// QuantumFault := fault { Leakage, QubitLoss, GateFailure }; +fault_set_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + "fault" ~ ws ~ "{" ~ ws ~ + error_set_body ~ ws ~ + "}" ~ ws ~ ";" +} + +error_set_body = { error_variant ~ (ws ~ "," ~ ws ~ error_variant)* ~ ws ~ ","? } + +// Error/fault variant can optionally have associated data +// Leakage: struct { gate: []const u8, qubit: usize } +error_variant = { identifier ~ (ws ~ ":" ~ ws ~ type_expr)? } + +// Test declaration (NASA Power of 10: assertions) +test_decl = { + "test" ~ ws ~ string_literal ~ ws ~ block +} + +// Custom gate declarations +// declare gate name(params)(qubits); -- target gate (provided by backend) +// gate name(params)(qubits) { body } -- composite gate (defined inline) +declare_gate_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + "declare" ~ ws ~ "gate" ~ ws ~ identifier ~ ws ~ + "(" ~ ws ~ gate_param_list? ~ ws ~ ")" ~ ws ~ + "(" ~ ws ~ qubit_param_list? ~ ws ~ ")" ~ ws ~ + ";" +} + +gate_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + "gate" ~ ws ~ identifier ~ ws ~ + "(" ~ ws ~ gate_param_list? ~ ws ~ ")" ~ ws ~ + "(" ~ ws ~ qubit_param_list? ~ ws ~ ")" ~ ws ~ + block +} + +gate_param_list = { gate_param ~ (ws ~ "," ~ ws ~ gate_param)* } +gate_param = { identifier ~ (ws ~ ":" ~ ws ~ type_expr)? } + +qubit_param_list = { qubit_param ~ (ws ~ "," ~ ws ~ qubit_param)* } +qubit_param = { identifier } + +// ============================================================================= +// Statements +// ============================================================================= + +statement = { + binding_decl | + alias_stmt | + assign_stmt | + if_stmt | + for_stmt | + switch_stmt | + tick_stmt | + try_block_stmt | + return_stmt | + break_stmt | + continue_stmt | + defer_stmt | + errdefer_stmt | + block | + expr_stmt +} + +// Alias statement - creates a named view into existing data +// alias name := slice_expr; +// Aliases are immutable views with overlap checking +alias_stmt = { + "alias" ~ ws ~ identifier ~ ws ~ ":" ~ ws ~ "=" ~ ws ~ expr ~ ws ~ ";" +} + +// Try blocks for error handling +// try { ... } - collect all errors (QEC pattern), returns []E!T +// try! { ... } - stop on first error (traditional), returns E!T +// Both can have optional catch clause +try_block_stmt = { try_collect_block | try_bang_block } + +// try { } - collect all errors, continue executing +try_collect_block = { + "try" ~ ws ~ block ~ ws ~ catch_clause? +} + +// try! { } - stop on first error, propagate immediately +try_bang_block = { + "try!" ~ ws ~ block ~ ws ~ catch_clause? +} + +// catch |err| { } or catch |err| expr +catch_clause = { + "catch" ~ ws ~ "|" ~ ws ~ identifier ~ ws ~ "|" ~ ws ~ (block | expr) +} + +// Tick block - a time slice of parallel quantum gates +// tick { ... } - anonymous tick +// tick name { ... } - named tick +// tick "name" { ... } - named tick with string literal +// @attr(round, 0) tick { ... } - tick with prefix attribute +// @attrs({round: 0, kind: "syndrome"}) tick { ... } - tick with multiple attributes +tick_stmt = { + attribute_list? ~ ws ~ + "tick" ~ ws ~ attribute_list? ~ ws ~ tick_label? ~ ws ~ tick_body +} +tick_label = { string_literal | identifier } +tick_body = { "{" ~ ws ~ (statement ~ ws)* ~ "}" } + +// Assignment: x = value; or x.field = value; or x[i] = value; +assign_stmt = { lvalue ~ ws ~ assign_op ~ ws ~ expr ~ ws ~ ";" } +assign_op = { "=" | "+=" | "-=" | "*=" | "/=" | "&=" | "|=" | "^=" } +lvalue = { primary_expr ~ (field_access | index_access)* } + +// If statement (Rust/Python-style, with Go-style unwrapping) +// Regular: if condition { ... } else { ... } +// Optional unwrapping: if value := opt { ... } else { ... } +if_stmt = { + "if" ~ ws ~ (if_unwrap_clause | if_condition) ~ ws ~ + (block | statement) ~ + (ws ~ "else" ~ ws ~ (if_stmt | block | statement))? +} + +// if value := optional { ... } (Go-style unwrapping) +if_unwrap_clause = { + identifier ~ ws ~ ":=" ~ ws ~ expr +} + +// Regular condition (no parentheses required) +if_condition = { expr } + +// For loop (bounded iteration - NASA Power of 10 compliant) +// Rust/Python-style syntax: +// for i in 0..n { ... } +// for item in items { ... } +// for i, item in items { ... } +for_stmt = { + label? ~ + (inline_keyword ~ ws)? ~ + "for" ~ ws ~ capture_list ~ ws ~ "in" ~ ws ~ for_range ~ ws ~ + block +} + +for_range = { range_lit | expr } +range_lit = { range_bound ~ ".." ~ range_bound } +// Range bound allows expressions including field access and arithmetic +range_bound = { range_bound_term ~ (ws ~ range_bound_op ~ ws ~ range_bound_term)* } +range_bound_term = { + number_literal | + range_bound_field_access | + identifier | + "(" ~ ws ~ range_bound ~ ws ~ ")" +} +range_bound_field_access = { identifier ~ ("." ~ member_name)+ } +range_bound_op = { "+" | "-" | "*" | "/" } +capture_list = { identifier ~ (ws ~ "," ~ ws ~ identifier)* } + +// Switch statement +switch_stmt = { + "switch" ~ ws ~ "(" ~ ws ~ expr ~ ws ~ ")" ~ ws ~ "{" ~ ws ~ + (switch_prong ~ ws)* ~ + "}" +} + +switch_prong = { + (switch_case ~ (ws ~ "," ~ ws ~ switch_case)* | "else") ~ ws ~ + "=>" ~ ws ~ (block | expr) ~ ws ~ ","? +} +switch_case = { expr ~ (ws ~ ".." ~ ws ~ expr)? } + +// Control flow +return_stmt = { "return" ~ (ws ~ expr)? ~ ws ~ ";" } +break_stmt = { "break" ~ (ws ~ ":" ~ identifier)? ~ (ws ~ expr)? ~ ws ~ ";" } +continue_stmt = { "continue" ~ (ws ~ ":" ~ identifier)? ~ ws ~ ";" } +defer_stmt = { "defer" ~ ws ~ (block | statement) } +errdefer_stmt = { "errdefer" ~ ws ~ ("|" ~ ws ~ identifier ~ ws ~ "|" ~ ws)? ~ (block | statement) } + +// Block (can be labeled for break) +// Supports optional trailing expression for return value (like Rust) +// Block with optional attributes and label +// @attr(kind, "syndrome") { ... } +// @attrs({round: 1}) my_block: { ... } +block = { attribute_list? ~ ws ~ label? ~ "{" ~ ws ~ (statement ~ ws)* ~ (trailing_expr ~ ws)? ~ "}" } +trailing_expr = { expr } +label = { identifier ~ ws ~ ":" ~ ws } + +// Expression statement (can have prefix attributes for gates) +// @attr(syndrome, "X") cx({(q[0], q[1])}); +expr_stmt = { attribute_list? ~ ws ~ expr ~ ws ~ ";" } + +// ============================================================================= +// Quantum Statements (integrated into expression/statement system) +// ============================================================================= + +// Quantum operations use DSL-like syntax: +// h q[0]; - single-qubit gate +// cx (q[0], q[1]); - two-qubit gate with tuple +// rx(0.123) q[0]; - parameterized gate +// h {q[0], q[1], q[2]}; - batch apply (set semantics) +// cx {(q[0], q[1]), (q[2], q[3])}; - batch two-qubit gates +// r := mz(u1) q[0]; - measurement with type +// pz q; - prepare all qubits +// pz {q[0], q[1]}; - prepare specific qubits + +// ============================================================================= +// Expressions +// ============================================================================= + +expr = { comptime_expr | runtime_expr } + +comptime_expr = { "comptime" ~ ws ~ (block | runtime_expr) } + +runtime_expr = { or_expr } + +// Binary operators with precedence (lowest to highest) +// orelse/catch have lowest precedence (for optional/error unwrapping) +// +// NOTE: Keyword operators (and_kw, or_kw, orelse_kw, catch_kw) are defined above +// with word boundary checks to prevent matching parts of identifiers. +or_expr = { catch_expr ~ (or_kw ~ catch_expr)* } +catch_expr = { orelse_expr ~ (catch_kw ~ ("|" ~ ws ~ identifier ~ ws ~ "|" ~ ws)? ~ orelse_expr)* } +orelse_expr = { and_expr ~ (orelse_kw ~ and_expr)* } +and_expr = { cmp_expr ~ (and_kw ~ cmp_expr)* } +cmp_expr = { bitwise_or_expr ~ (ws ~ cmp_op ~ ws ~ bitwise_or_expr)? } +cmp_op = { "==" | "!=" | "<=" | ">=" | "<" | ">" | not_in_op | in_op } +in_op = { "in" } +not_in_op = { "not" ~ ws ~ "in" } + +bitwise_or_expr = { bitwise_xor_expr ~ (ws ~ bitor_op ~ ws ~ bitwise_xor_expr)* } +bitor_op = { "|" } +bitwise_xor_expr = { bitwise_and_expr ~ (ws ~ bitxor_op ~ ws ~ bitwise_and_expr)* } +bitxor_op = { "^" } +bitwise_and_expr = { shift_expr ~ (ws ~ bitand_op ~ ws ~ shift_expr)* } +bitand_op = { "&" } +shift_expr = { add_expr ~ (ws ~ shift_op ~ ws ~ add_expr)* } +shift_op = { "<<" | ">>" } +add_expr = { suffixed_expr ~ (ws ~ add_op ~ ws ~ suffixed_expr)* } +add_op = { "+" | "-" } +mul_expr = { unary_expr ~ (ws ~ mul_op ~ ws ~ unary_expr)* } +mul_op = { "*" | "/" | "%" } + +// Suffixed expression: expression with optional type/unit suffix +// Allows space between expression and suffix for readability: +// 0.25 turns, 1/4 turns - angle units +// 42 u32, 1/4 f64 - type suffixes +// (a + b) i64 - typed expression result +suffixed_expr = { mul_expr ~ (ws ~ expr_suffix)? } + +// Expression suffix: angle units or type names +// Angle units: turns (native), rad (radians) +// Type ascription: numeric types for explicit typing +expr_suffix = { angle_unit | type_ascription_suffix } +angle_unit = { ("turns" | "rad") ~ !(ASCII_ALPHANUMERIC | "_") } +type_ascription_suffix = { (float_type_kw | int_type_kw) ~ !(ASCII_ALPHANUMERIC | "_") } +float_type_kw = @{ "f128" | "f64" | "f32" | "f16" | "a64" } +int_type_kw = @{ + "u128" | "u64" | "u32" | "u16" | "u8" | "usize" | + "i128" | "i64" | "i32" | "i16" | "i8" | "isize" +} + +// Unary operators +// "try" is for error propagation: try foo() returns payload or propagates error +// Note: "try" unary must not be followed by "{" (try block) or "!" (try!) +unary_expr = { (unary_op ~ ws)* ~ postfix_expr } +unary_op = { try_unary | "-" | "!" | "~" | "&" | "*" } +try_unary = { "try" ~ !("{" | "!") } + +// Postfix expressions +postfix_expr = { primary_expr ~ postfix_op* } +postfix_op = { call | batch_apply | field_access | index_access | optional_unwrap | error_unwrap } + +call = { "(" ~ ws ~ arg_list? ~ ws ~ ")" } +arg_list = { expr ~ (ws ~ "," ~ ws ~ expr)* } + +// Batch apply: h { q[0], q[1] } or cx { (q[0], q[1]), (q[2], q[3]) } +// For gates where order doesn't matter (set semantics) +batch_apply = { ws ~ "{" ~ ws ~ batch_elements? ~ ws ~ "}" } +batch_elements = { expr ~ (ws ~ "," ~ ws ~ expr)* ~ ","? } + +field_access = { "." ~ ws ~ member_name } +index_access = { "[" ~ ws ~ (range_expr | expr) ~ ws ~ "]" } +optional_unwrap = { ".?" } +error_unwrap = { "." ~ "!" } + +range_expr = { expr? ~ ".." ~ expr? } + +// Primary expressions +// Split into recursive expressions (that contain `expr`) and atomic expressions. +// Atomic expressions use compound-atomic ($) to fix parsing issues with keyword +// operators (and, or, etc.) when boolean literals appear on the left side. +primary_expr = { + paren_or_tuple | // (a) or (a, b) - combined to avoid backtracking + if_expr | + try_block_expr | // try { } or try! { } as expression + block_expr | + struct_literal | + enum_literal | + struct_init | + array_init | + array_type_expr | // [N]T as expression (type value) + bracket_array | // [a, b, c] - array literal + set_literal | // set { a, b, c } - set literal + measure_expr | // mz(T) [targets] - measurement with ordered results + channel_expr | // >channel.command(...) - side-channel communication + result_expr | // result("tag", value) - emit results to caller (special, never elided) + builtin_call | + fn_literal | + f_string | // f"Hello {name}" - must come before string_literal + gate_expr | // h q[0], rx(0.123) q[0] - quantum gates (late to avoid x orelse confusion) + atom // Compound-atomic leaf expressions +} + +// Atomic (leaf) expressions that don't recursively contain `expr`. +// Using compound-atomic ($) to prevent implicit WHITESPACE from interfering +// with keyword operator matching (e.g., `true and y`). +atom = ${ + number_literal | + raw_string | // r"path\to\file" - raw string, no escapes + multiline_string | // """...""" - must come before string_literal + string_literal | + char_literal | + bool_literal | + none_literal | + undefined_literal | + unit_literal | + self_expr | + error_value | // error.Name - error value literal + fault_value | // fault.Name - fault value literal + identifier +} + +// Try block as expression (for assignments) +// errors := try { ... }; +// result := try! { ... } catch |err| { default }; +try_block_expr = { try_collect_expr | try_bang_expr } + +try_collect_expr = { + "try" ~ ws ~ block ~ ws ~ catch_clause? +} + +try_bang_expr = { + "try!" ~ ws ~ block ~ ws ~ catch_clause? +} + +// Measurement expression: mz(T) targets or mz(pack T) targets +// +// Per-qubit mode (no pack): N qubits → [N]T, count must match exactly +// mz(u1) q[0] - single qubit → u1 +// mz([4]u1) [q[0], q[1], q[2], q[3]] - 4 qubits → [4]u1 +// +// Pack mode: bits fill T sequentially, must have enough capacity +// mz(pack u8) [q[0], ..., q[7]] - 8 qubits → u8 +// mz(pack Syndrome) ancillas - N qubits → Syndrome struct +// +measure_expr = { "mz" ~ ws ~ "(" ~ ws ~ pack_modifier? ~ type_expr ~ ws ~ ")" ~ ws ~ measure_target } +pack_modifier = { "pack" ~ ws } +measure_target = { bracket_array | postfix_expr } + +// ============================================================================= +// Channel Expressions: >channel.command(args) +// ============================================================================= +// +// Unified syntax for all side-channel communication (sticky/barrier semantics). +// All channel expressions use the form: >channel.command(args) +// +// Built-in channels: +// @emit.log.trace(f"message") - logging at trace level +// @emit.log.debug("ns", f"message") - logging with sub-namespace +// @emit.log.info(f"msg", data: obj) - logging with structured data +// @emit.log.at(50, f"custom level") - logging with custom numeric level +// @emit.sim.send("key", value) - simulator message +// @emit.sim.noise_enable() - enable noise (default) +// @emit.sim.noise_disable() - disable noise +// @emit.hw.send("key", value) - hardware message +// +// Custom channels: +// @emit.timing.send("checkpoint", t) - timing instrumentation +// @emit.debug.send("state", snapshot) - debug snapshots +// +// Channel behavior is controlled by --target and configuration: +// - log: elided in release mode +// - sim: barrier for hardware, active for simulator +// - hw: active for hardware, elided for simulator +// - custom: configurable +// +channel_expr = { + emit_prefix ~ ws ~ channel_name ~ ws ~ "." ~ ws ~ channel_command +} + +// @emit prefix for side-channel communication +emit_prefix = { "@emit" ~ ws ~ "." } + +// Channel name (log, sim, hw, timing, etc.) +channel_name = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } + +channel_command = { + channel_command_name ~ ws ~ "(" ~ ws ~ channel_args? ~ ws ~ ")" +} + +// Use member_name to allow keywords like 'error' as channel commands +channel_command_name = @{ member_name } + +channel_args = { + channel_arg ~ (ws ~ "," ~ ws ~ channel_arg)* +} + +// Named or positional argument +channel_arg = { + (identifier ~ ws ~ ":" ~ ws ~ expr) | + expr +} + +// ============================================================================= +// Result Expression (Program Output Channel) +// ============================================================================= +// +// result(tag, value) emits a tagged value as program output. +// This is the primary way to return data from quantum programs to the caller. +// Unlike logs, results are NEVER elided - they're essential program outputs. +// +// Syntax: +// result("measurement", m) - simple result +// result("qec/syndrome", syndrome) - namespaced with / convention +// result("round_1/parity", parity) - hierarchical naming +// +// The tag must be a compile-time string literal (like Guppy). +// Value can be any serializable type: int, bool, float, arrays. +// +result_expr = { + "result" ~ ws ~ "(" ~ ws ~ string_literal ~ ws ~ "," ~ ws ~ expr ~ ws ~ ")" +} + + +// Gate expression: gate target or gate(params) target +// Consistent with measurement syntax - params in parens, target follows +// +// Non-parameterized single: h q[0] +// Non-parameterized batch: h {q[0], q[1]} +// Parameterized single: rx(0.123) q[0] +// Parameterized batch: rx(0.123) {q[0], q[1]} +// Two-qubit single: cx (q[0], q[1]) +// Two-qubit batch: cx {(q[0], q[1]), (q[2], q[3])} +// +// Note: Old syntax like h(q[0]) or rz(1.57, q[0]) is NOT supported +// Parameterized gates MUST have params, non-parameterized gates MUST NOT +gate_expr = { param_gate_expr | simple_gate_expr } +param_gate_expr = { param_gate_keyword ~ ws ~ gate_params ~ ws ~ gate_target } +simple_gate_expr = { simple_gate_keyword ~ ws ~ gate_target } +gate_params = { "(" ~ ws ~ arg_list ~ ws ~ ")" } +// Gate target must be a qubit expression, not a bare keyword or paren expr +// This prevents "x orelse" from parsing as gate(x, target=orelse) +// and prevents "h(q[0])" from parsing as gate h with target (q[0]) +gate_target = { gate_set_target | paren_or_tuple | bracket_array | gate_qubit_target } +// gate_qubit_target must NOT start with "(" - that would be call syntax or tuple +// Use negative lookahead to reject "(expr)" patterns +gate_qubit_target = { !operator_keyword ~ !paren_or_tuple ~ postfix_expr } +operator_keyword = { "and" | "or" | "orelse" | "catch" } +gate_set_target = { "{" ~ ws ~ batch_elements? ~ ws ~ "}" } + +// Gate keywords - all quantum gates and prepare operations (lowercase only) +// Order matters: longer matches first to avoid prefix issues +// Must not be followed by alphanumeric or underscore (to avoid matching 'true' as 't' + 'rue') + +// Parameterized gates - REQUIRE angle parameters: rx(angle), ry(angle), etc. +param_gate_keyword = @{ + ( + // Two-qubit parameterized gates + "rzz" | "crz" | + // Single-qubit rotation gates + "rx" | "ry" | "rz" + ) ~ !(ASCII_ALPHANUMERIC | "_") +} + +// Non-parameterized gates - take only qubit targets, NO angle parameters +simple_gate_keyword = @{ + ( + // Daggers and compound gates (longer names first) + "sxxdg" | "syydg" | "szzdg" | + "sxdg" | "sydg" | "szdg" | + "f4dg" | "fdg" | + "iswap" | "swap" | + "tdg" | + // Two-qubit gates (non-parameterized) + "sxx" | "syy" | "szz" | + "ccx" | + "cx" | "cy" | "cz" | "ch" | + // Single-qubit compound gates + "sx" | "sy" | "sz" | + "f4" | + // Simple single-qubit gates (shortest last) + "x" | "y" | "z" | + "h" | "t" | "f" | + // Prepare operation (reset to |0⟩ state) + "pz" + ) ~ !(ASCII_ALPHANUMERIC | "_") +} + +// Combined for convenience (used in semantic analysis) +gate_keyword = @{ param_gate_keyword | simple_gate_keyword } + +// Error value literal: error.OutOfMemory, error.InvalidArgument, etc. +error_value = { "error" ~ "." ~ identifier } + +// Fault value literal: fault.Leakage, fault.QubitLoss, etc. +fault_value = { "fault" ~ "." ~ identifier } + +// Array type as expression (types are first-class values like in Zig) +array_type_expr = { "[" ~ ws ~ (array_size | "_")? ~ ws ~ (":" ~ ws ~ expr ~ ws)? ~ "]" ~ ws ~ type_identifier } + +// Combined parenthesized/tuple expression +// Parses (expr) as paren_expr, (expr,) or (expr, expr, ...) as tuple +// This avoids exponential backtracking on deeply nested parens by committing after "(" +paren_or_tuple = { "(" ~ ws ~ expr ~ (ws ~ "," ~ ws ~ expr)* ~ (ws ~ ",")? ~ ws ~ ")" } + +// Bracket array literal: [a, b, c] - for quantum batch operations +bracket_array = { "[" ~ ws ~ bracket_array_elements? ~ ws ~ "]" } +bracket_array_elements = { expr ~ (ws ~ "," ~ ws ~ expr)* ~ ","? } + +// Set literal: set { a, b, c } - unique unordered elements +// Consistent with struct { }, enum { } pattern +set_literal = { "set" ~ ws ~ "{" ~ ws ~ set_elements? ~ ws ~ "}" } +set_elements = { expr ~ (ws ~ "," ~ ws ~ expr)* ~ ","? } + +// Set type: Set(T) +set_type = { "Set" ~ ws ~ "(" ~ ws ~ type_expr ~ ws ~ ")" } + +// Struct as expression (anonymous struct type) +struct_literal = { + (packed_keyword ~ ws)? ~ "struct" ~ ws ~ "{" ~ ws ~ struct_body ~ ws ~ "}" +} + +// Enum as expression (anonymous enum type) +enum_literal = { + "enum" ~ ws ~ ("(" ~ ws ~ type_expr ~ ws ~ ")")? ~ ws ~ "{" ~ ws ~ enum_body ~ ws ~ "}" +} + +// If as expression (returns value) +// Parentheses required for condition to disambiguate from then-block +// if (condition) { then } else { else } +if_expr = { + "if" ~ ws ~ "(" ~ ws ~ expr ~ ws ~ ")" ~ ws ~ block ~ ws ~ "else" ~ ws ~ (if_expr | block) +} + +// Labeled block expression +// Supports optional trailing expression for return value (like Rust) +// Labeled block expression with optional attributes +// @attr(kind, "init") blk: { ... } +block_expr = { attribute_list? ~ ws ~ label ~ "{" ~ ws ~ (statement ~ ws)* ~ (trailing_expr ~ ws)? ~ "}" } + +// Struct initialization: .{ field: value, ... } or Type { field: value } +// Uses Rust-style syntax: `field: value` instead of Zig's `.field = value` +struct_init = { typed_struct_init | anon_struct_init } +typed_struct_init = { type_identifier ~ ws ~ "{" ~ ws ~ field_init_list? ~ ws ~ "}" } +anon_struct_init = { ".{" ~ ws ~ field_init_list? ~ ws ~ "}" } +field_init_list = { field_init ~ (ws ~ "," ~ ws ~ field_init)* ~ ","? } +// Rust-style field init: `name: value` or shorthand `name` (when var name matches field) +field_init = { identifier ~ ws ~ (":" ~ ws ~ expr)? } + +// Array/slice initialization +array_init = { typed_array_init | anon_array_init } +typed_array_init = { array_type ~ ws ~ "{" ~ ws ~ array_init_list? ~ ws ~ "}" } +anon_array_init = { ".{" ~ ws ~ array_init_list? ~ ws ~ "}" } +array_init_list = { expr ~ (ws ~ "," ~ ws ~ expr)* ~ ","? } + +// Builtin calls: @import, @This, @intCast, etc. +builtin_call = { "@" ~ identifier ~ "(" ~ ws ~ arg_list? ~ ws ~ ")" } + +// Anonymous function +fn_literal = { + "fn" ~ ws ~ "(" ~ ws ~ param_list? ~ ws ~ ")" ~ ws ~ + return_type? ~ ws ~ block +} + +// Self reference (Rust-style, replaces Zig's @This()) +self_expr = { "Self" ~ !(ASCII_ALPHANUMERIC | "_") } + +// ============================================================================= +// Types +// ============================================================================= + +// Type expression - avoid left recursion by using suffixes +type_expr = { type_prefix ~ type_suffix? } + +// Type prefixes (things that come before the base type) +type_prefix = { + optional_type | + pointer_type | + array_type | + tuple_type | + fn_type | + set_type | + struct_literal | + enum_literal | + builtin_type | + type_identifier +} + +// Tuple type: (T1, T2) or (T1, T2, T3, ...) +tuple_type = { "(" ~ ws ~ type_prefix ~ ws ~ "," ~ ws ~ type_prefix ~ (ws ~ "," ~ ws ~ type_prefix)* ~ ws ~ ","? ~ ws ~ ")" } + +// Type suffix for error unions: T!E (payload!error) +exclamation = _{ "!" } +type_suffix = { exclamation ~ type_prefix } + +// Optional type: ?T +optional_type = { "?" ~ ws ~ type_prefix } + +// Pointer types: *T, [*]T (many-pointer), [*:sentinel]T (sentinel-terminated many-pointer) +pointer_type = { pointer_prefix ~ ws ~ ("const" ~ ws)? ~ type_prefix } +pointer_prefix = { "[*:" ~ ws ~ expr ~ ws ~ "]" | "[*]" | "*" } + +// Array types: [N]T, []T (slice), [N:sentinel]T +array_type = { + "[" ~ ws ~ (array_size | "_")? ~ ws ~ (":" ~ ws ~ expr ~ ws)? ~ "]" ~ ws ~ type_prefix +} + +// Array size - allows comptime expressions like N + 1, N * 2, etc. +array_size = { array_size_term ~ (ws ~ array_size_op ~ ws ~ array_size_term)* } +array_size_term = { number_literal | identifier | "(" ~ ws ~ array_size ~ ws ~ ")" } +array_size_op = { "+" | "-" | "*" | "/" } + +// Function type: fn(args) -> return_type +fn_type = { "fn" ~ "(" ~ ws ~ type_list? ~ ws ~ ")" ~ ws ~ ("->" ~ ws ~ type_prefix)? } +type_list = { type_prefix ~ (ws ~ "," ~ ws ~ type_prefix)* } + +// Built-in types +builtin_type = { + self_type | int_type | float_type | angle_type | "bool" | "unit" | "type" | "anytype" | + "qubit" | "bit" +} + +// Self type - refers to the enclosing struct type (Rust-style) +self_type = { "Self" ~ !(ASCII_ALPHANUMERIC | "_") } + +angle_type = @{ "a64" } + +// Integer types: arbitrary bit width like Zig (u1, u4, u7, u128, usize, etc.) +int_type = @{ + ("u" | "i") ~ ("size" | ASCII_DIGIT+) +} + +float_type = @{ "f" ~ ("16" | "32" | "64" | "128") } + +// Named type (struct, enum, etc.) +// Also accepts Self keyword as a type +type_identifier = { (identifier | self_type_keyword) ~ ("." ~ identifier)* } +self_type_keyword = { "Self" ~ !(ASCII_ALPHANUMERIC | "_") } + +// ============================================================================= +// Literals +// ============================================================================= + +// Number literals with optional type suffix +// Examples: 42, 42u32, 42_u8, 0xFF_u16, 1.5, 1.5f32 +number_literal = @{ + hex_literal | binary_literal | octal_literal | float_literal | int_literal +} + +hex_literal = @{ "0x" ~ (ASCII_HEX_DIGIT | "_")+ ~ int_suffix? } +binary_literal = @{ "0b" ~ ("0" | "1" | "_")+ ~ int_suffix? } +octal_literal = @{ "0o" ~ (ASCII_OCT_DIGIT | "_")+ ~ int_suffix? } +// Float: 1.5, 1.5e10, 1e10 (scientific notation without decimal) +float_literal = @{ int_literal_base ~ ("." ~ ASCII_DIGIT+ ~ exp_part? | exp_part) ~ float_suffix? } +exp_part = @{ ("e" | "E") ~ ("+" | "-")? ~ ASCII_DIGIT+ } +int_literal = @{ int_literal_base ~ int_suffix? } +int_literal_base = @{ ASCII_DIGIT ~ (ASCII_DIGIT | "_")* } + +// Type suffixes for literals +int_suffix = @{ "_"? ~ ( + "u128" | "u64" | "u32" | "u16" | "u8" | "u1" | "usize" | + "i128" | "i64" | "i32" | "i16" | "i8" | "i1" | "isize" +) } +float_suffix = @{ "_"? ~ ("f128" | "f64" | "f32" | "f16" | "a64") } + +string_literal = @{ "\"" ~ (escape_seq | (!("\"" | "\\") ~ ANY))* ~ "\"" } +char_literal = @{ "'" ~ (escape_seq | (!("'" | "\\") ~ ANY)) ~ "'" } +escape_seq = @{ "\\" ~ ("n" | "r" | "t" | "\\" | "\"" | "'" | "0" | "{" | "}" | "x" ~ ASCII_HEX_DIGIT{2}) } + +// F-string (Python-style string interpolation): f"Hello {name}, you have {count} items" +// Expressions inside {} are evaluated and converted to strings +// Use \{ and \} to escape literal braces +// Format specifiers: f"{x:.2f}", f"{name:>10}", f"{n:08d}" +f_string = { "f\"" ~ f_string_part* ~ "\"" } +f_string_part = { f_string_interp | f_string_text } +f_string_interp = { "{" ~ ws ~ expr ~ ws ~ f_string_format? ~ "}" } +f_string_format = { ":" ~ f_string_format_spec } +f_string_format_spec = @{ (!("}") ~ ANY)* } +f_string_text = @{ (escape_seq | (!("\"" | "\\" | "{" | "}") ~ ANY))+ } + +// Raw string (Python-style): r"path\to\file" - backslashes are literal, not escapes +// Useful for file paths, regex patterns, etc. +raw_string = @{ "r\"" ~ (!"\"" ~ ANY)* ~ "\"" } + +// Multi-line string (Python-style): """...""" - preserves newlines and whitespace +// Content is taken literally except for escape sequences +// Use for multi-line text, documentation, or embedded DSLs +multiline_string = @{ "\"\"\"" ~ multiline_string_content ~ "\"\"\"" } +multiline_string_content = @{ (escape_seq | (!"\"\"\"" ~ ANY))* } + +// Word boundary check to prevent matching prefix of identifier (e.g., "true_flag") +bool_literal = { ("true" | "false") ~ !(ASCII_ALPHANUMERIC | "_") } +// none instead of null (consistent with other lowercase keywords) +none_literal = { "none" ~ !(ASCII_ALPHANUMERIC | "_") } +undefined_literal = { "undefined" ~ !(ASCII_ALPHANUMERIC | "_") } +unit_literal = { "unit" ~ !(ASCII_ALPHANUMERIC | "_") } + +// ============================================================================= +// Identifiers and Keywords +// ============================================================================= + +identifier = @{ + !keyword ~ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* +} + +// Member name - allows keywords as field/method names (like Rust's raw identifiers) +// Used in struct field declarations, method definitions, and field access +member_name = @{ + (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* +} + +// Reserved keywords (qalloc, Reg are not keywords - they're callable builtins) +// Keywords must be followed by non-alphanumeric to ensure word boundary +// Note: "try!" is handled specially in fn_error_mode, not as a keyword +keyword = @{ + ("fn" | "pub" | "inline" | "comptime" | "mut" | + "struct" | "enum" | "union" | "packed" | "set" | + "if" | "else" | "for" | "switch" | "tick" | + "return" | "break" | "continue" | "defer" | "errdefer" | + "and" | "or" | "orelse" | "try" | "catch" | + "true" | "false" | "none" | "undefined" | "Self" | + "test" | "error" | "type" | "anytype" | "unit" | + "qubit" | "bit" | "alias" | + "turns" | "rad") ~ !(ASCII_ALPHANUMERIC | "_") +} + +// ============================================================================= +// Documentation Comments +// ============================================================================= + +doc_comment = @{ "///" ~ (!NEWLINE ~ ANY)* ~ NEWLINE } + +// ============================================================================= +// Quantum-Specific Builtins (recognized at semantic level) +// ============================================================================= + +// These are parsed as regular function calls but have special semantics: +// +// Allocator operations: +// qalloc(capacity) - Create base allocator +// alloc.child(size) - Create child allocator +// alloc.release() - Explicit release (usually automatic) +// +// Prepare operations (use pz gate instead of method calls): +// pz alloc - Prepare all qubits in allocator +// pz {q[0], q[1]} - Prepare specific qubits +// +// Gates (single qubit): +// h(q[i]), x(q[i]), y(q[i]), z(q[i]) +// s(q[i]), sdg(q[i]), t(q[i]), tdg(q[i]) +// sx(q[i]), sy(q[i]), sz(q[i]) +// rx(q[i], angle), ry(q[i], angle), rz(q[i], angle) +// +// Gates (two qubit): +// cx(ctrl, target), cy(ctrl, target), cz(ctrl, target) +// ch(ctrl, target) +// sxx(q1, q2), syy(q1, q2), szz(q1, q2) +// rzz(q1, q2, angle) +// +// Measurement: +// measure(q[i]) -> bit +// measure(alloc) -> [N]bit +// +// Builtins: +// @import(path) +// @This() +// @compileError(msg) +// @compileLog(...) +// @typeInfo(T) +// @typeName(T) diff --git a/exp/zlup/std/a64.zlp b/exp/zlup/std/a64.zlp new file mode 100644 index 000000000..57d16952f --- /dev/null +++ b/exp/zlup/std/a64.zlp @@ -0,0 +1,76 @@ +/// Standard library: a64 angle constants +/// +/// All angles are in turns (the native unit for a64). +/// 1 turn = full rotation = 2*pi radians = 360 degrees +/// +/// Usage: +/// std := @import("std"); +/// rz(std.a64.quarter_turn turns) q[0]; +/// +/// Common quantum gate angles: +/// - T-gate: 1/8 turn = pi/4 rad = 45 deg +/// - S-gate: 1/4 turn = pi/2 rad = 90 deg +/// - Z-gate: 1/2 turn = pi rad = 180 deg + +// ============================================================================= +// Fundamental Turn Fractions (exact in Angle64) +// ============================================================================= + +/// Zero angle +pub zero: a64 = 0 turns; + +/// Full turn (equivalent to zero due to periodicity) +pub full_turn: a64 = 1 turns; + +/// Half turn = pi radians = 180 degrees (Z-gate equivalent) +pub half_turn: a64 = 1/2 turns; + +/// Quarter turn = pi/2 radians = 90 degrees (S-gate) +pub quarter_turn: a64 = 1/4 turns; + +/// Three-quarter turn = 3*pi/2 radians = 270 degrees +pub three_quarter_turn: a64 = 3/4 turns; + +/// Eighth turn = pi/4 radians = 45 degrees (T-gate) +pub eighth_turn: a64 = 1/8 turns; + +/// Three-eighths turn = 3*pi/4 radians = 135 degrees +pub three_eighth_turn: a64 = 3/8 turns; + +/// Sixteenth turn = pi/8 radians = 22.5 degrees +pub sixteenth_turn: a64 = 1/16 turns; + +// ============================================================================= +// Gate-Named Aliases (for convenience) +// ============================================================================= + +/// T-gate angle = 1/8 turn = pi/4 radians +pub t_angle: a64 = 1/8 turns; + +/// T-dagger angle = -1/8 turn = -pi/4 radians = 7/8 turns +pub tdg_angle: a64 = 7/8 turns; + +/// S-gate angle = 1/4 turn = pi/2 radians +pub s_angle: a64 = 1/4 turns; + +/// S-dagger angle = -1/4 turn = -pi/2 radians = 3/4 turns +pub sdg_angle: a64 = 3/4 turns; + +/// Z-gate angle = 1/2 turn = pi radians +pub z_angle: a64 = 1/2 turns; + +// ============================================================================= +// Thirds (exact in Angle64) +// ============================================================================= + +/// Third turn = 2*pi/3 radians = 120 degrees +pub third_turn: a64 = 1/3 turns; + +/// Two-thirds turn = 4*pi/3 radians = 240 degrees +pub two_third_turn: a64 = 2/3 turns; + +/// Sixth turn = pi/3 radians = 60 degrees +pub sixth_turn: a64 = 1/6 turns; + +/// Twelfth turn = pi/6 radians = 30 degrees +pub twelfth_turn: a64 = 1/12 turns; diff --git a/exp/zlup/std/algorithm.zlp b/exp/zlup/std/algorithm.zlp new file mode 100644 index 000000000..5f9726235 --- /dev/null +++ b/exp/zlup/std/algorithm.zlp @@ -0,0 +1,344 @@ +/// Standard library: Simple algorithms +/// +/// Fixed-size algorithms for common operations. +/// All bounds are explicit and checked. + +// ============================================================================= +// Swap Operations +// ============================================================================= + +/// Swap two u32 values (returns (b, a) as packed u64) +/// To unpack: a = result >> 32, b = result & 0xFFFFFFFF +pub fn swap_u32_packed(a: u32, b: u32) -> u64 { + high: u64 = b; + low: u64 = a; + return (high << 32) | low; +} + +// ============================================================================= +// Linear Search (Fixed-Size Arrays) +// ============================================================================= + +/// Search for value in 4-element array +/// Returns index (0-3) if found, 4 if not found +pub fn linear_search_4_u32(v0: u32, v1: u32, v2: u32, v3: u32, target: u32) -> u32 { + if v0 == target { return 0; } + if v1 == target { return 1; } + if v2 == target { return 2; } + if v3 == target { return 3; } + return 4; +} + +/// Search for value in 8-element array +pub fn linear_search_8_u32(v0: u32, v1: u32, v2: u32, v3: u32, + v4: u32, v5: u32, v6: u32, v7: u32, target: u32) -> u32 { + if v0 == target { return 0; } + if v1 == target { return 1; } + if v2 == target { return 2; } + if v3 == target { return 3; } + if v4 == target { return 4; } + if v5 == target { return 5; } + if v6 == target { return 6; } + if v7 == target { return 7; } + return 8; +} + +// ============================================================================= +// Min/Max in Fixed-Size Arrays +// ============================================================================= + +/// Find minimum of 4 values +pub fn min4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + mut m := v0; + if v1 < m { m = v1; } + if v2 < m { m = v2; } + if v3 < m { m = v3; } + return m; +} + +/// Find maximum of 4 values +pub fn max4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + mut m := v0; + if v1 > m { m = v1; } + if v2 > m { m = v2; } + if v3 > m { m = v3; } + return m; +} + +/// Find minimum of 8 values +pub fn min8_u32(v0: u32, v1: u32, v2: u32, v3: u32, + v4: u32, v5: u32, v6: u32, v7: u32) -> u32 { + m0 := min4_u32(v0, v1, v2, v3); + m1 := min4_u32(v4, v5, v6, v7); + if m0 < m1 { return m0; } + return m1; +} + +/// Find maximum of 8 values +pub fn max8_u32(v0: u32, v1: u32, v2: u32, v3: u32, + v4: u32, v5: u32, v6: u32, v7: u32) -> u32 { + m0 := max4_u32(v0, v1, v2, v3); + m1 := max4_u32(v4, v5, v6, v7); + if m0 > m1 { return m0; } + return m1; +} + +/// Find index of minimum in 4 values +pub fn argmin4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + mut idx: u32 = 0; + mut m := v0; + if v1 < m { m = v1; idx = 1; } + if v2 < m { m = v2; idx = 2; } + if v3 < m { idx = 3; } + return idx; +} + +/// Find index of maximum in 4 values +pub fn argmax4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + mut idx: u32 = 0; + mut m := v0; + if v1 > m { m = v1; idx = 1; } + if v2 > m { m = v2; idx = 2; } + if v3 > m { idx = 3; } + return idx; +} + +// ============================================================================= +// Sum and Product +// ============================================================================= + +/// Sum of 4 values +pub fn sum4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + return v0 + v1 + v2 + v3; +} + +/// Sum of 8 values +pub fn sum8_u32(v0: u32, v1: u32, v2: u32, v3: u32, + v4: u32, v5: u32, v6: u32, v7: u32) -> u32 { + return v0 + v1 + v2 + v3 + v4 + v5 + v6 + v7; +} + +/// Product of 4 values +pub fn prod4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + return v0 * v1 * v2 * v3; +} + +// ============================================================================= +// Counting +// ============================================================================= + +/// Count values equal to target in 4 elements +pub fn count4_eq_u32(v0: u32, v1: u32, v2: u32, v3: u32, target: u32) -> u32 { + mut c: u32 = 0; + if v0 == target { c = c + 1; } + if v1 == target { c = c + 1; } + if v2 == target { c = c + 1; } + if v3 == target { c = c + 1; } + return c; +} + +/// Count values less than threshold in 4 elements +pub fn count4_lt_u32(v0: u32, v1: u32, v2: u32, v3: u32, threshold: u32) -> u32 { + mut c: u32 = 0; + if v0 < threshold { c = c + 1; } + if v1 < threshold { c = c + 1; } + if v2 < threshold { c = c + 1; } + if v3 < threshold { c = c + 1; } + return c; +} + +/// Count values greater than threshold in 4 elements +pub fn count4_gt_u32(v0: u32, v1: u32, v2: u32, v3: u32, threshold: u32) -> u32 { + mut c: u32 = 0; + if v0 > threshold { c = c + 1; } + if v1 > threshold { c = c + 1; } + if v2 > threshold { c = c + 1; } + if v3 > threshold { c = c + 1; } + return c; +} + +// ============================================================================= +// All/Any Predicates +// ============================================================================= + +/// Check if all 4 values equal target +pub fn all4_eq_u32(v0: u32, v1: u32, v2: u32, v3: u32, target: u32) -> bool { + return v0 == target and v1 == target and v2 == target and v3 == target; +} + +/// Check if any of 4 values equals target +pub fn any4_eq_u32(v0: u32, v1: u32, v2: u32, v3: u32, target: u32) -> bool { + return v0 == target or v1 == target or v2 == target or v3 == target; +} + +/// Check if all 4 values are less than threshold +pub fn all4_lt_u32(v0: u32, v1: u32, v2: u32, v3: u32, threshold: u32) -> bool { + return v0 < threshold and v1 < threshold and v2 < threshold and v3 < threshold; +} + +/// Check if any of 4 values is less than threshold +pub fn any4_lt_u32(v0: u32, v1: u32, v2: u32, v3: u32, threshold: u32) -> bool { + return v0 < threshold or v1 < threshold or v2 < threshold or v3 < threshold; +} + +// ============================================================================= +// Sorting Networks (Fixed-Size) +// ============================================================================= + +/// Compare-and-swap: returns (min, max) packed as (min << 32) | max +fn cas_u32(a: u32, b: u32) -> u64 { + if a <= b { + high: u64 = a; + low: u64 = b; + return (high << 32) | low; + } + high: u64 = b; + low: u64 = a; + return (high << 32) | low; +} + +/// Sort 2 values ascending, returns (min << 32) | max +pub fn sort2_u32(a: u32, b: u32) -> u64 { + return cas_u32(a, b); +} + +/// Sort 3 values ascending +/// Returns packed: (v0 << 42) | (v1 << 21) | v2 (21 bits each, assumes values < 2M) +/// For full range, use sort3_u32_to_array or successive extractions +pub fn sort3_u32_packed(a: u32, b: u32, c: u32) -> u64 { + // Sorting network for 3 elements + // Compare a,b + r1 := cas_u32(a, b); + lo1: u32 = (r1 & 0xFFFFFFFF); + hi1: u32 = (r1 >> 32); + + // Compare hi1,c + r2 := cas_u32(hi1, c); + mid: u32 = (r2 & 0xFFFFFFFF); + hi2: u32 = (r2 >> 32); + + // Compare lo1,mid + r3 := cas_u32(lo1, mid); + v0: u32 = (r3 >> 32); + v1: u32 = (r3 & 0xFFFFFFFF); + v2: u32 = hi2; + + // Pack as 21-bit values (assuming values fit) + result: u64 = 0; + x0: u64 = v0; + x1: u64 = v1; + x2: u64 = v2; + result = (x0 << 42) | (x1 << 21) | x2; + return result; +} + +/// Median of 3 values +pub fn median3_u32(a: u32, b: u32, c: u32) -> u32 { + // Using sorting network approach + if a <= b { + if b <= c { + return b; // a <= b <= c + } + if a <= c { + return c; // a <= c < b + } + return a; // c < a <= b + } + // b < a + if a <= c { + return a; // b < a <= c + } + if b <= c { + return c; // b <= c < a + } + return b; // c < b < a +} + +// ============================================================================= +// Bit Counting in Multiple Values +// ============================================================================= + +/// Count total set bits across 4 u8 values +pub fn total_bits_4_u8(v0: u8, v1: u8, v2: u8, v3: u8) -> u32 { + // Inline popcount for each + mut total: u32 = 0; + + mut n := v0; + for _ in 0..8 { + if n == 0 { break; } + n = n & (n - 1); + total = total + 1; + } + + n = v1; + for _ in 0..8 { + if n == 0 { break; } + n = n & (n - 1); + total = total + 1; + } + + n = v2; + for _ in 0..8 { + if n == 0 { break; } + n = n & (n - 1); + total = total + 1; + } + + n = v3; + for _ in 0..8 { + if n == 0 { break; } + n = n & (n - 1); + total = total + 1; + } + + return total; +} + +// ============================================================================= +// Prefix Sum (Scan) +// ============================================================================= + +/// Exclusive prefix sum of 4 values +/// Returns packed: (s0 << 48) | (s1 << 32) | (s2 << 16) | s3 +/// where s0=0, s1=v0, s2=v0+v1, s3=v0+v1+v2 +pub fn prefix_sum4_u16(v0: u16, v1: u16, v2: u16, v3: u16) -> u64 { + s0: u64 = 0; + s1: u64 = v0; + s2: u64 = v0 + v1; + s3: u64 = v0 + v1 + v2; + return (s0 << 48) | (s1 << 32) | (s2 << 16) | s3; +} + +// ============================================================================= +// Hamming Distance +// ============================================================================= + +/// Hamming distance between two u32 values (count of differing bits) +pub fn hamming_u32(a: u32, b: u32) -> u32 { + diff := a ^ b; + mut count: u32 = 0; + mut n := diff; + for _ in 0..32 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Hamming distance between two u64 values +pub fn hamming_u64(a: u64, b: u64) -> u64 { + diff := a ^ b; + mut count: u64 = 0; + mut n := diff; + for _ in 0..64 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} diff --git a/exp/zlup/std/bits.zlp b/exp/zlup/std/bits.zlp new file mode 100644 index 000000000..77ccd44e7 --- /dev/null +++ b/exp/zlup/std/bits.zlp @@ -0,0 +1,458 @@ +/// Standard library: Bitwise operations for syndrome processing +/// +/// Usage: +/// std := @import("std"); +/// weight := std.bits.popcount_u8(syndrome); +/// parity := std.bits.parity_u8(syndrome); + +// ============================================================================= +// Popcount (Count Set Bits) +// ============================================================================= + +/// Count the number of 1 bits in a u8 +pub fn popcount_u8(x: u8) -> u8 { + // Brian Kernighan's algorithm + mut count: u8 = 0; + mut n := x; + for _ in 0..8 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Count the number of 1 bits in a u16 +pub fn popcount_u16(x: u16) -> u16 { + mut count: u16 = 0; + mut n := x; + for _ in 0..16 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Count the number of 1 bits in a u32 +pub fn popcount_u32(x: u32) -> u32 { + mut count: u32 = 0; + mut n := x; + for _ in 0..32 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Count the number of 1 bits in a u64 +pub fn popcount_u64(x: u64) -> u64 { + mut count: u64 = 0; + mut n := x; + for _ in 0..64 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +// ============================================================================= +// Parity (XOR of All Bits) +// ============================================================================= + +/// Compute parity of a u8 (1 if odd number of 1s, 0 if even) +pub fn parity_u8(x: u8) -> u8 { + mut p := x; + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Compute parity of a u16 +pub fn parity_u16(x: u16) -> u16 { + mut p := x; + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Compute parity of a u32 +pub fn parity_u32(x: u32) -> u32 { + mut p := x; + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Compute parity of a u64 +pub fn parity_u64(x: u64) -> u64 { + mut p := x; + p = p ^ (p >> 32); + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +// ============================================================================= +// Bit Extraction and Manipulation +// ============================================================================= + +/// Get a single bit from a u8 (returns 0 or 1) +pub fn get_bit_u8(x: u8, index: u8) -> u8 { + if index >= 8 { + return 0; + } + return (x >> index) & 1; +} + +/// Get a single bit from a u32 (returns 0 or 1) +pub fn get_bit_u32(x: u32, index: u32) -> u32 { + if index >= 32 { + return 0; + } + return (x >> index) & 1; +} + +/// Get a single bit from a u64 (returns 0 or 1) +pub fn get_bit_u64(x: u64, index: u64) -> u64 { + if index >= 64 { + return 0; + } + return (x >> index) & 1; +} + +/// Set a single bit in a u8 +pub fn set_bit_u8(x: u8, index: u8, value: u8) -> u8 { + if index >= 8 { + return x; + } + mask: u8 = 1 << index; + if value == 1 { + return x | mask; + } else { + return x & (~mask); + } +} + +/// Set a single bit in a u32 +pub fn set_bit_u32(x: u32, index: u32, value: u32) -> u32 { + if index >= 32 { + return x; + } + mask: u32 = 1 << index; + if value == 1 { + return x | mask; + } else { + return x & (~mask); + } +} + +/// Set a single bit in a u64 +pub fn set_bit_u64(x: u64, index: u64, value: u64) -> u64 { + if index >= 64 { + return x; + } + mask: u64 = 1 << index; + if value == 1 { + return x | mask; + } else { + return x & (~mask); + } +} + +/// Toggle a single bit in a u8 +pub fn toggle_bit_u8(x: u8, index: u8) -> u8 { + if index >= 8 { + return x; + } + mask: u8 = 1 << index; + return x ^ mask; +} + +/// Toggle a single bit in a u32 +pub fn toggle_bit_u32(x: u32, index: u32) -> u32 { + if index >= 32 { + return x; + } + mask: u32 = 1 << index; + return x ^ mask; +} + +/// Toggle a single bit in a u64 +pub fn toggle_bit_u64(x: u64, index: u64) -> u64 { + if index >= 64 { + return x; + } + mask: u64 = 1 << index; + return x ^ mask; +} + +/// Extract a range of bits from a u32 +/// Returns bits [start, start+len) shifted to LSB position +pub fn extract_bits_u32(x: u32, start: u32, len: u32) -> u32 { + if start >= 32 or len == 0 { + return 0; + } + mut actual_len := len; + if start + len > 32 { + actual_len = 32 - start; + } + one: u32 = 1; + mask := (one << actual_len) - 1; + return (x >> start) & mask; +} + +/// Extract a range of bits from a u64 +pub fn extract_bits_u64(x: u64, start: u64, len: u64) -> u64 { + if start >= 64 or len == 0 { + return 0; + } + mut actual_len := len; + if start + len > 64 { + actual_len = 64 - start; + } + one: u64 = 1; + mask := (one << actual_len) - 1; + return (x >> start) & mask; +} + +// ============================================================================= +// Rotation +// ============================================================================= + +/// Rotate left for u32 +pub fn rotl_u32(x: u32, n: u32) -> u32 { + shift := n % 32; + if shift == 0 { + return x; + } + return (x << shift) | (x >> (32 - shift)); +} + +/// Rotate right for u32 +pub fn rotr_u32(x: u32, n: u32) -> u32 { + shift := n % 32; + if shift == 0 { + return x; + } + return (x >> shift) | (x << (32 - shift)); +} + +/// Rotate left for u64 +pub fn rotl_u64(x: u64, n: u64) -> u64 { + shift := n % 64; + if shift == 0 { + return x; + } + return (x << shift) | (x >> (64 - shift)); +} + +/// Rotate right for u64 +pub fn rotr_u64(x: u64, n: u64) -> u64 { + shift := n % 64; + if shift == 0 { + return x; + } + return (x >> shift) | (x << (64 - shift)); +} + +// ============================================================================= +// Leading/Trailing Zeros +// ============================================================================= + +/// Count leading zeros in a u32 +pub fn clz_u32(x: u32) -> u32 { + if x == 0 { + return 32; + } + mut n: u32 = 0; + mut val := x; + if (val & 0xFFFF0000) == 0 { n = n + 16; val = val << 16; } + if (val & 0xFF000000) == 0 { n = n + 8; val = val << 8; } + if (val & 0xF0000000) == 0 { n = n + 4; val = val << 4; } + if (val & 0xC0000000) == 0 { n = n + 2; val = val << 2; } + if (val & 0x80000000) == 0 { n = n + 1; } + return n; +} + +/// Count trailing zeros in a u32 +pub fn ctz_u32(x: u32) -> u32 { + if x == 0 { + return 32; + } + mut n: u32 = 0; + mut val := x; + if (val & 0x0000FFFF) == 0 { n = n + 16; val = val >> 16; } + if (val & 0x000000FF) == 0 { n = n + 8; val = val >> 8; } + if (val & 0x0000000F) == 0 { n = n + 4; val = val >> 4; } + if (val & 0x00000003) == 0 { n = n + 2; val = val >> 2; } + if (val & 0x00000001) == 0 { n = n + 1; } + return n; +} + +/// Count leading zeros in a u64 +pub fn clz_u64(x: u64) -> u64 { + if x == 0 { + return 64; + } + // Check high 32 bits first + if (x >> 32) != 0 { + // High bits have data, count zeros there + mut n: u64 = 0; + mut val := x >> 32; + if (val & 0xFFFF0000) == 0 { n = n + 16; val = val << 16; } + if (val & 0xFF000000) == 0 { n = n + 8; val = val << 8; } + if (val & 0xF0000000) == 0 { n = n + 4; val = val << 4; } + if (val & 0xC0000000) == 0 { n = n + 2; val = val << 2; } + if (val & 0x80000000) == 0 { n = n + 1; } + return n; + } + // High bits are zero, count in low bits + 32 + mut n: u64 = 32; + mut val := x; + if (val & 0xFFFF0000) == 0 { n = n + 16; val = val << 16; } + if (val & 0xFF000000) == 0 { n = n + 8; val = val << 8; } + if (val & 0xF0000000) == 0 { n = n + 4; val = val << 4; } + if (val & 0xC0000000) == 0 { n = n + 2; val = val << 2; } + if (val & 0x80000000) == 0 { n = n + 1; } + return n; +} + +/// Count trailing zeros in a u64 +pub fn ctz_u64(x: u64) -> u64 { + if x == 0 { + return 64; + } + // Check low 32 bits first + if (x & 0xFFFFFFFF) != 0 { + // Low bits have data + mut n: u64 = 0; + mut val := x; + if (val & 0x0000FFFF) == 0 { n = n + 16; val = val >> 16; } + if (val & 0x000000FF) == 0 { n = n + 8; val = val >> 8; } + if (val & 0x0000000F) == 0 { n = n + 4; val = val >> 4; } + if (val & 0x00000003) == 0 { n = n + 2; val = val >> 2; } + if (val & 0x00000001) == 0 { n = n + 1; } + return n; + } + // Low bits are zero, count in high bits + 32 + mut n: u64 = 32; + mut val := x >> 32; + if (val & 0x0000FFFF) == 0 { n = n + 16; val = val >> 16; } + if (val & 0x000000FF) == 0 { n = n + 8; val = val >> 8; } + if (val & 0x0000000F) == 0 { n = n + 4; val = val >> 4; } + if (val & 0x00000003) == 0 { n = n + 2; val = val >> 2; } + if (val & 0x00000001) == 0 { n = n + 1; } + return n; +} + +// ============================================================================= +// Bit Reversal +// ============================================================================= + +/// Reverse bits in a u8 +pub fn reverse_u8(x: u8) -> u8 { + mut v := x; + v = ((v & 0xF0) >> 4) | ((v & 0x0F) << 4); + v = ((v & 0xCC) >> 2) | ((v & 0x33) << 2); + v = ((v & 0xAA) >> 1) | ((v & 0x55) << 1); + return v; +} + +/// Reverse bits in a u32 +pub fn reverse_u32(x: u32) -> u32 { + mut v := x; + v = ((v & 0xFFFF0000) >> 16) | ((v & 0x0000FFFF) << 16); + v = ((v & 0xFF00FF00) >> 8) | ((v & 0x00FF00FF) << 8); + v = ((v & 0xF0F0F0F0) >> 4) | ((v & 0x0F0F0F0F) << 4); + v = ((v & 0xCCCCCCCC) >> 2) | ((v & 0x33333333) << 2); + v = ((v & 0xAAAAAAAA) >> 1) | ((v & 0x55555555) << 1); + return v; +} + +// ============================================================================= +// Byte Swap +// ============================================================================= + +/// Swap bytes in a u16 (endianness conversion) +pub fn byte_swap_u16(x: u16) -> u16 { + return ((x & 0xFF) << 8) | ((x >> 8) & 0xFF); +} + +/// Swap bytes in a u32 (endianness conversion) +pub fn byte_swap_u32(x: u32) -> u32 { + return ((x & 0x000000FF) << 24) | + ((x & 0x0000FF00) << 8) | + ((x & 0x00FF0000) >> 8) | + ((x & 0xFF000000) >> 24); +} + +/// Swap bytes in a u64 (endianness conversion) +pub fn byte_swap_u64(x: u64) -> u64 { + mut v := x; + v = ((v & 0x00000000FFFFFFFF) << 32) | ((v >> 32) & 0x00000000FFFFFFFF); + v = ((v & 0x0000FFFF0000FFFF) << 16) | ((v >> 16) & 0x0000FFFF0000FFFF); + v = ((v & 0x00FF00FF00FF00FF) << 8) | ((v >> 8) & 0x00FF00FF00FF00FF); + return v; +} + +// ============================================================================= +// Syndrome-specific Operations +// ============================================================================= + +/// XOR two syndromes (for computing syndrome differences) +pub fn xor_u64(a: u64, b: u64) -> u64 { + return a ^ b; +} + +/// Check if syndrome indicates no errors (all zeros) +pub fn is_trivial_syndrome(syndrome: u64) -> bool { + return syndrome == 0; +} + +/// Get the index of the lowest set bit (useful for finding first defect) +/// Returns 64 if no bits are set +pub fn lowest_set_bit_u64(x: u64) -> u64 { + if x == 0 { + return 64; + } + return ctz_u64(x); +} + +/// Get the index of the highest set bit +/// Returns 64 if no bits are set +pub fn highest_set_bit_u64(x: u64) -> u64 { + if x == 0 { + return 64; + } + return 63 - clz_u64(x); +} + +/// Clear the lowest set bit +pub fn clear_lowest_bit_u64(x: u64) -> u64 { + return x & (x - 1); +} + +/// Isolate the lowest set bit (returns value with only that bit set) +pub fn isolate_lowest_bit_u64(x: u64) -> u64 { + return x & (~x + 1); +} diff --git a/exp/zlup/std/bits.zlup b/exp/zlup/std/bits.zlup new file mode 100644 index 000000000..e6bec753f --- /dev/null +++ b/exp/zlup/std/bits.zlup @@ -0,0 +1,176 @@ +/// Bitwise utilities for working with measurement results. +/// Provides functions for bit manipulation and parity calculations. + +// ============================================================================= +// Bit Counting +// ============================================================================= + +/// Count the number of set bits (population count/Hamming weight). +/// Useful for computing parity of measurement results. +/// +/// Example: +/// ```zlup +/// result: u8 = 0b10110100; +/// weight := popcount_u8(result); // 4 +/// ``` +pub fn popcount_u8(x: u8) -> u8 { + mut count: u8 = 0; + mut val := x; + for i in 0..8 { + count += val & 1; + val = val >> 1; + } + return count; +} + +/// Count set bits in a u16. +pub fn popcount_u16(x: u16) -> u16 { + mut count: u16 = 0; + mut val := x; + for i in 0..16 { + count += val & 1; + val = val >> 1; + } + return count; +} + +/// Count set bits in a u32. +pub fn popcount_u32(x: u32) -> u32 { + mut count: u32 = 0; + mut val := x; + for i in 0..32 { + count += val & 1; + val = val >> 1; + } + return count; +} + +/// Count set bits in a u64. +pub fn popcount_u64(x: u64) -> u64 { + mut count: u64 = 0; + mut val := x; + for i in 0..64 { + count += val & 1; + val = val >> 1; + } + return count; +} + +// ============================================================================= +// Parity +// ============================================================================= + +/// Compute the parity (XOR of all bits) of a u8. +/// Returns 0 if even number of 1s, 1 if odd. +/// +/// Example: +/// ```zlup +/// // Syndrome parity check +/// syndrome: u8 = mz(pack u8) ancillas; +/// if parity_u8(syndrome) == 1 { +/// // Odd parity - error detected +/// } +/// ``` +pub fn parity_u8(x: u8) -> u1 { + return (popcount_u8(x) & 1); +} + +/// Compute parity of a u16. +pub fn parity_u16(x: u16) -> u1 { + return (popcount_u16(x) & 1); +} + +/// Compute parity of a u32. +pub fn parity_u32(x: u32) -> u1 { + return (popcount_u32(x) & 1); +} + +/// Compute parity of a u64. +pub fn parity_u64(x: u64) -> u1 { + return (popcount_u64(x) & 1); +} + +// ============================================================================= +// Bit Extraction +// ============================================================================= + +/// Extract a single bit from a u8 at the given index. +/// Index 0 is the least significant bit. +/// +/// Example: +/// ```zlup +/// result: u8 = 0b10110100; +/// bit2 := get_bit_u8(result, 2); // 1 +/// bit0 := get_bit_u8(result, 0); // 0 +/// ``` +pub fn get_bit_u8(x: u8, index: u8) -> u1 { + return (x >> index) & 1; +} + +/// Extract a single bit from a u16. +pub fn get_bit_u16(x: u16, index: u16) -> u1 { + return (x >> index) & 1; +} + +/// Extract a single bit from a u32. +pub fn get_bit_u32(x: u32, index: u32) -> u1 { + return (x >> index) & 1; +} + +/// Extract a single bit from a u64. +pub fn get_bit_u64(x: u64, index: u64) -> u1 { + return (x >> index) & 1; +} + +// ============================================================================= +// Bit Manipulation +// ============================================================================= + +/// Set a bit to 1 at the given index. +pub fn set_bit_u8(x: u8, index: u8) -> u8 { + return x | (1 << index); +} + +/// Clear a bit to 0 at the given index. +pub fn clear_bit_u8(x: u8, index: u8) -> u8 { + return x & ~(1 << index); +} + +/// Toggle a bit at the given index. +pub fn toggle_bit_u8(x: u8, index: u8) -> u8 { + return x ^ (1 << index); +} + +// ============================================================================= +// Byte Order +// ============================================================================= + +/// Reverse the bits in a u8. +/// +/// Example: +/// ```zlup +/// x: u8 = 0b10110100; +/// reversed := reverse_bits_u8(x); // 0b00101101 +/// ``` +pub fn reverse_bits_u8(x: u8) -> u8 { + mut result: u8 = 0; + mut val := x; + for i in 0..8 { + result = (result << 1) | (val & 1); + val = val >> 1; + } + return result; +} + +/// Swap bytes in a u16 (big-endian to little-endian or vice versa). +pub fn swap_bytes_u16(x: u16) -> u16 { + return ((x & 0xFF) << 8) | ((x >> 8) & 0xFF); +} + +/// Swap bytes in a u32. +pub fn swap_bytes_u32(x: u32) -> u32 { + return ((x & 0xFF) << 24) | + ((x & 0xFF00) << 8) | + ((x >> 8) & 0xFF00) | + ((x >> 24) & 0xFF); +} diff --git a/exp/zlup/std/containers.zlup b/exp/zlup/std/containers.zlup new file mode 100644 index 000000000..06e0385d7 --- /dev/null +++ b/exp/zlup/std/containers.zlup @@ -0,0 +1,381 @@ +/// Standard library containers for Zluppy. +/// All containers have bounded capacity for NASA Power of 10 compliance. + +/// Error set for container operations. +pub OverflowError := error { Overflow }; + +/// Bounded stack (LIFO) container. +/// +/// Example: +/// ```zlup +/// stack: Stack(u32, 64) = .{}; +/// try stack.push(42); +/// if val := stack.pop() { +/// // use val +/// } +/// ``` +pub Stack := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + /// Internal storage + items: [capacity]T = undefined, + /// Current number of elements + len: usize = 0, + + /// Push an item onto the stack. + /// Returns error.Overflow if the stack is full. + pub fn push(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + self.items[self.len] = item; + self.len += 1; + return unit; + } + + /// Pop an item from the stack. + /// Returns none if the stack is empty. + pub fn pop(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + self.len -= 1; + return self.items[self.len]; + } + + /// Peek at the top item without removing it. + /// Returns none if the stack is empty. + pub fn peek(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + return self.items[self.len - 1]; + } + + /// Check if the stack is empty. + pub fn is_empty(&mut self) -> bool { + return self.len == 0; + } + + /// Check if the stack is full. + pub fn is_full(&mut self) -> bool { + return self.len >= capacity; + } + + /// Clear all items from the stack. + pub fn clear(&mut self) -> unit { + self.len = 0; + return unit; + } + + /// Get the current number of items. + pub fn count(&mut self) -> usize { + return self.len; + } + + /// Get the maximum capacity. + pub fn get_capacity(&mut self) -> usize { + return capacity; + } + } +}; + +/// Bounded queue (FIFO) container using a ring buffer. +/// +/// Example: +/// ```zlup +/// queue: Queue(u32, 64) = .{}; +/// try queue.enqueue(42); +/// if val := queue.dequeue() { +/// // use val +/// } +/// ``` +pub Queue := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + /// Internal storage (ring buffer) + items: [capacity]T = undefined, + /// Index of the front element + head: usize = 0, + /// Index where the next element will be inserted + tail: usize = 0, + /// Current number of elements + len: usize = 0, + + /// Add an item to the back of the queue. + /// Returns error.Overflow if the queue is full. + pub fn enqueue(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + self.items[self.tail] = item; + self.tail = (self.tail + 1) % capacity; + self.len += 1; + return unit; + } + + /// Remove and return the front item. + /// Returns none if the queue is empty. + pub fn dequeue(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + item := self.items[self.head]; + self.head = (self.head + 1) % capacity; + self.len -= 1; + return item; + } + + /// Peek at the front item without removing it. + /// Returns none if the queue is empty. + pub fn peek_front(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + return self.items[self.head]; + } + + /// Check if the queue is empty. + pub fn is_empty(&mut self) -> bool { + return self.len == 0; + } + + /// Check if the queue is full. + pub fn is_full(&mut self) -> bool { + return self.len >= capacity; + } + + /// Clear all items from the queue. + pub fn clear(&mut self) -> unit { + self.head = 0; + self.tail = 0; + self.len = 0; + return unit; + } + + /// Get the current number of items. + pub fn count(&mut self) -> usize { + return self.len; + } + } +}; + +/// Bounded double-ended queue (deque) container. +/// Supports efficient insertion and removal at both ends. +/// +/// Example: +/// ```zlup +/// deque: Deque(u32, 64) = .{}; +/// try deque.push_back(42); +/// try deque.push_front(1); +/// if val := deque.pop_front() { +/// // use val +/// } +/// ``` +pub Deque := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + /// Internal storage (ring buffer) + items: [capacity]T = undefined, + /// Index of the front element + head: usize = 0, + /// Current number of elements + len: usize = 0, + + /// Add an item to the back of the deque. + /// Returns error.Overflow if the deque is full. + pub fn push_back(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + tail := (self.head + self.len) % capacity; + self.items[tail] = item; + self.len += 1; + return unit; + } + + /// Add an item to the front of the deque. + /// Returns error.Overflow if the deque is full. + pub fn push_front(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + self.head = (self.head + capacity - 1) % capacity; + self.items[self.head] = item; + self.len += 1; + return unit; + } + + /// Remove and return the back item. + /// Returns none if the deque is empty. + pub fn pop_back(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + self.len -= 1; + tail := (self.head + self.len) % capacity; + return self.items[tail]; + } + + /// Remove and return the front item. + /// Returns none if the deque is empty. + pub fn pop_front(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + item := self.items[self.head]; + self.head = (self.head + 1) % capacity; + self.len -= 1; + return item; + } + + /// Peek at the front item without removing it. + pub fn peek_front(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + return self.items[self.head]; + } + + /// Peek at the back item without removing it. + pub fn peek_back(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + tail := (self.head + self.len - 1) % capacity; + return self.items[tail]; + } + + /// Check if the deque is empty. + pub fn is_empty(&mut self) -> bool { + return self.len == 0; + } + + /// Check if the deque is full. + pub fn is_full(&mut self) -> bool { + return self.len >= capacity; + } + + /// Clear all items from the deque. + pub fn clear(&mut self) -> unit { + self.head = 0; + self.len = 0; + return unit; + } + + /// Get the current number of items. + pub fn count(&mut self) -> usize { + return self.len; + } + } +}; + +/// Bounded priority queue (min-heap). +/// Elements are ordered by priority, with the smallest element at the front. +/// +/// Example: +/// ```zlup +/// pq: PriorityQueue(u32, 64) = .{}; +/// try pq.insert(42); +/// try pq.insert(10); +/// if val := pq.extract_min() { +/// // val is 10 +/// } +/// ``` +pub PriorityQueue := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + /// Internal storage (binary heap) + items: [capacity]T = undefined, + /// Current number of elements + len: usize = 0, + + /// Insert an item into the priority queue. + /// Returns error.Overflow if the queue is full. + pub fn insert(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + // Add at the end + self.items[self.len] = item; + // Bubble up + self.bubble_up(self.len); + self.len += 1; + return unit; + } + + /// Remove and return the minimum element. + /// Returns none if the queue is empty. + pub fn extract_min(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + min := self.items[0]; + self.len -= 1; + if (self.len > 0) { + self.items[0] = self.items[self.len]; + self.bubble_down(0); + } + return min; + } + + /// Peek at the minimum element without removing it. + pub fn peek_min(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + return self.items[0]; + } + + /// Check if the queue is empty. + pub fn is_empty(&mut self) -> bool { + return self.len == 0; + } + + // Internal: bubble up element at index i + // Max iterations is log2(capacity) which is bounded by capacity + fn bubble_up(&mut self, i: usize) -> unit { + mut idx := i; + // Bounded loop: at most capacity iterations (actually log2(capacity)) + for _ in 0..capacity { + if (idx == 0) { + break; + } + parent := (idx - 1) / 2; + if (self.items[idx] >= self.items[parent]) { + break; + } + // Swap + tmp := self.items[idx]; + self.items[idx] = self.items[parent]; + self.items[parent] = tmp; + idx = parent; + } + return unit; + } + + // Internal: bubble down element at index i + // Max iterations is log2(capacity) which is bounded by capacity + fn bubble_down(&mut self, i: usize) -> unit { + mut idx := i; + // Bounded loop: at most capacity iterations (actually log2(capacity)) + for _ in 0..capacity { + smallest := idx; + left := 2 * idx + 1; + right := 2 * idx + 2; + + if (left < self.len and self.items[left] < self.items[smallest]) { + smallest = left; + } + if (right < self.len and self.items[right] < self.items[smallest]) { + smallest = right; + } + if (smallest == idx) { + break; + } + // Swap + tmp := self.items[idx]; + self.items[idx] = self.items[smallest]; + self.items[smallest] = tmp; + idx = smallest; + } + return unit; + } + } +}; diff --git a/exp/zlup/std/f64.zlp b/exp/zlup/std/f64.zlp new file mode 100644 index 000000000..633b4d5c2 --- /dev/null +++ b/exp/zlup/std/f64.zlp @@ -0,0 +1,72 @@ +/// Standard library: f64 constants +/// +/// Usage: +/// std := @import("std"); +/// angle := std.f64.pi / 4.0; // pi/4 as f64 +/// +/// Or import directly: +/// f64_consts := @import("std/f64.zlp"); +/// angle := f64_consts.pi / 4.0; + +// ============================================================================= +// Mathematical Constants (f64) +// ============================================================================= + +/// Pi - ratio of circumference to diameter +pub pi: f64 = 3.14159265358979323846; + +/// Tau - 2*pi, full circle in radians +pub tau: f64 = 6.28318530717958647692; + +/// Euler's number +pub e: f64 = 2.71828182845904523536; + +/// Square root of 2 +pub sqrt2: f64 = 1.41421356237309504880; + +/// 1 / sqrt(2) - common in quantum gates +pub sqrt2_inv: f64 = 0.70710678118654752440; + +/// Square root of 3 +pub sqrt3: f64 = 1.73205080756887729353; + +/// Natural logarithm of 2 +pub ln2: f64 = 0.69314718055994530942; + +/// Natural logarithm of 10 +pub ln10: f64 = 2.30258509299404568402; + +// ============================================================================= +// Common Angle Fractions in Radians +// ============================================================================= + +/// pi/2 - quarter turn in radians +pub pi_2: f64 = 1.57079632679489661923; + +/// pi/4 - eighth turn in radians (T-gate) +pub pi_4: f64 = 0.78539816339744830962; + +/// pi/8 - sixteenth turn in radians +pub pi_8: f64 = 0.39269908169872415481; + +/// pi/3 - sixth turn in radians +pub pi_3: f64 = 1.04719755119659774615; + +/// pi/6 - twelfth turn in radians +pub pi_6: f64 = 0.52359877559829887308; + +// ============================================================================= +// Conversion Factors +// ============================================================================= + +/// Degrees to radians: multiply degrees by this +pub deg_to_rad: f64 = 0.01745329251994329577; + +/// Radians to degrees: multiply radians by this +pub rad_to_deg: f64 = 57.29577951308232087680; + +/// Turns to radians: multiply turns by this (= 2*pi) +pub turns_to_rad: f64 = 6.28318530717958647692; + +/// Radians to turns: multiply radians by this (= 1/(2*pi)) +pub rad_to_turns: f64 = 0.15915494309189533577; diff --git a/exp/zlup/std/math.zlp b/exp/zlup/std/math.zlp new file mode 100644 index 000000000..928e6a6a7 --- /dev/null +++ b/exp/zlup/std/math.zlp @@ -0,0 +1,523 @@ +/// Standard library: Mathematical functions +/// +/// For constants (pi, tau, e, etc.), use std.f64: +/// std := @import("std"); +/// angle := std.f64.pi / 4.0; +/// +/// For angle constants, use std.a64: +/// theta := std.a64.quarter_turn; + +// ============================================================================= +// Functions +// ============================================================================= + +/// Absolute value +pub fn abs_i32(x: i32) -> i32 { + if x < 0 { + return -x; + } + return x; +} + +/// Absolute value for i64 +pub fn abs_i64(x: i64) -> i64 { + if x < 0 { + return -x; + } + return x; +} + +/// Absolute value for f64 +pub fn abs_f64(x: f64) -> f64 { + if x < 0.0 { + return -x; + } + return x; +} + +/// Minimum of two i32 values +pub fn min_i32(a: i32, b: i32) -> i32 { + if a < b { + return a; + } + return b; +} + +/// Minimum of two u32 values +pub fn min_u32(a: u32, b: u32) -> u32 { + if a < b { + return a; + } + return b; +} + +/// Minimum of two f64 values +pub fn min_f64(a: f64, b: f64) -> f64 { + if a < b { + return a; + } + return b; +} + +/// Maximum of two i32 values +pub fn max_i32(a: i32, b: i32) -> i32 { + if a > b { + return a; + } + return b; +} + +/// Maximum of two u32 values +pub fn max_u32(a: u32, b: u32) -> u32 { + if a > b { + return a; + } + return b; +} + +/// Maximum of two f64 values +pub fn max_f64(a: f64, b: f64) -> f64 { + if a > b { + return a; + } + return b; +} + +/// Clamp value to range [lo, hi] +pub fn clamp_i32(x: i32, lo: i32, hi: i32) -> i32 { + if x < lo { + return lo; + } + if x > hi { + return hi; + } + return x; +} + +/// Clamp value to range [lo, hi] +pub fn clamp_u32(x: u32, lo: u32, hi: u32) -> u32 { + if x < lo { + return lo; + } + if x > hi { + return hi; + } + return x; +} + +/// Clamp value to range [lo, hi] +pub fn clamp_f64(x: f64, lo: f64, hi: f64) -> f64 { + if x < lo { + return lo; + } + if x > hi { + return hi; + } + return x; +} + +/// Sign of a number: -1, 0, or 1 +pub fn sign_i32(x: i32) -> i32 { + if x < 0 { + return -1; + } + if x > 0 { + return 1; + } + return 0; +} + +/// Sign of a number: -1.0, 0.0, or 1.0 +pub fn sign_f64(x: f64) -> f64 { + if x < 0.0 { + return -1.0; + } + if x > 0.0 { + return 1.0; + } + return 0.0; +} + +/// Integer division (floor division for positive numbers) +pub fn div_floor_i32(a: i32, b: i32) -> i32 { + result := a / b; + // Adjust for negative results to get floor behavior + if (a < 0) != (b < 0) and a % b != 0 { + return result - 1; + } + return result; +} + +/// Modulo operation (always non-negative for positive divisor) +pub fn mod_i32(a: i32, b: i32) -> i32 { + result := a % b; + if result < 0 { + return result + b; + } + return result; +} + +/// Check if integer is power of 2 +pub fn is_power_of_2_u32(x: u32) -> bool { + if x == 0 { + return false; + } + return (x & (x - 1)) == 0; +} + +/// Check if integer is power of 2 +pub fn is_power_of_2_u64(x: u64) -> bool { + if x == 0 { + return false; + } + return (x & (x - 1)) == 0; +} + +/// Next power of 2 greater than or equal to x +pub fn next_power_of_2_u32(x: u32) -> u32 { + if x == 0 { + return 1; + } + mut n := x - 1; + n = n | (n >> 1); + n = n | (n >> 2); + n = n | (n >> 4); + n = n | (n >> 8); + n = n | (n >> 16); + return n + 1; +} + +// ============================================================================= +// GCD and LCM +// ============================================================================= + +/// Greatest common divisor (Euclidean algorithm) +pub fn gcd_u32(a: u32, b: u32) -> u32 { + mut x := a; + mut y := b; + for _ in 0..32 { + if y == 0 { + return x; + } + t := y; + y = x % y; + x = t; + } + return x; +} + +/// Greatest common divisor for u64 +pub fn gcd_u64(a: u64, b: u64) -> u64 { + mut x := a; + mut y := b; + for _ in 0..64 { + if y == 0 { + return x; + } + t := y; + y = x % y; + x = t; + } + return x; +} + +/// Least common multiple +pub fn lcm_u32(a: u32, b: u32) -> u32 { + if a == 0 or b == 0 { + return 0; + } + return (a / gcd_u32(a, b)) * b; +} + +/// Least common multiple for u64 +pub fn lcm_u64(a: u64, b: u64) -> u64 { + if a == 0 or b == 0 { + return 0; + } + return (a / gcd_u64(a, b)) * b; +} + +// ============================================================================= +// Integer Square Root +// ============================================================================= + +/// Integer square root (floor of sqrt) +/// Uses Newton-Raphson iteration +pub fn isqrt_u32(x: u32) -> u32 { + if x == 0 { + return 0; + } + if x == 1 { + return 1; + } + + // Initial guess (half the bit width) + mut r := x / 2; + + // Newton-Raphson: r = (r + x/r) / 2 + for _ in 0..16 { + new_r := (r + x / r) / 2; + if new_r >= r { + return r; + } + r = new_r; + } + return r; +} + +/// Integer square root for u64 +pub fn isqrt_u64(x: u64) -> u64 { + if x == 0 { + return 0; + } + if x == 1 { + return 1; + } + + mut r := x / 2; + + for _ in 0..32 { + new_r := (r + x / r) / 2; + if new_r >= r { + return r; + } + r = new_r; + } + return r; +} + +// ============================================================================= +// Integer Logarithm +// ============================================================================= + +/// Floor of log base 2 (position of highest set bit) +/// Returns 0 for x=0 or x=1 +pub fn log2_u32(x: u32) -> u32 { + if x <= 1 { + return 0; + } + + mut n: u32 = 0; + mut val := x; + + if val >= 65536 { n = n + 16; val = val >> 16; } + if val >= 256 { n = n + 8; val = val >> 8; } + if val >= 16 { n = n + 4; val = val >> 4; } + if val >= 4 { n = n + 2; val = val >> 2; } + if val >= 2 { n = n + 1; } + + return n; +} + +/// Floor of log base 2 for u64 +pub fn log2_u64(x: u64) -> u64 { + if x <= 1 { + return 0; + } + + mut n: u64 = 0; + mut val := x; + + if val >= 4294967296 { n = n + 32; val = val >> 32; } + if val >= 65536 { n = n + 16; val = val >> 16; } + if val >= 256 { n = n + 8; val = val >> 8; } + if val >= 16 { n = n + 4; val = val >> 4; } + if val >= 4 { n = n + 2; val = val >> 2; } + if val >= 2 { n = n + 1; } + + return n; +} + +/// Ceiling of log base 2 +pub fn log2_ceil_u32(x: u32) -> u32 { + if x <= 1 { + return 0; + } + floor := log2_u32(x); + one: u32 = 1; + if (one << floor) == x { + return floor; + } + return floor + 1; +} + +/// Ceiling of log base 2 for u64 +pub fn log2_ceil_u64(x: u64) -> u64 { + if x <= 1 { + return 0; + } + floor := log2_u64(x); + one: u64 = 1; + if (one << floor) == x { + return floor; + } + return floor + 1; +} + +// ============================================================================= +// Factorial and Binomial (Small Numbers) +// ============================================================================= + +/// Factorial for small n (n <= 12 for u32, n <= 20 for u64) +/// Returns 0 on overflow +pub fn factorial_u32(n: u32) -> u32 { + if n > 12 { + return 0; // Overflow + } + mut result: u32 = 1; + mut i: u32 = 2; + for _ in 0..12 { + if i > n { + break; + } + result = result * i; + i = i + 1; + } + return result; +} + +/// Factorial for small n (max n=20) +pub fn factorial_u64(n: u64) -> u64 { + if n > 20 { + return 0; // Overflow + } + mut result: u64 = 1; + mut i: u64 = 2; + for _ in 0..20 { + if i > n { + break; + } + result = result * i; + i = i + 1; + } + return result; +} + +/// Binomial coefficient C(n, k) = n! / (k! * (n-k)!) +/// Uses multiplicative formula to avoid large intermediate values +pub fn binomial_u32(n: u32, k: u32) -> u32 { + if k > n { + return 0; + } + if k == 0 or k == n { + return 1; + } + + // Use smaller k for efficiency: C(n,k) = C(n, n-k) + mut kk := k; + if k > n - k { + kk = n - k; + } + + mut result: u32 = 1; + mut i: u32 = 0; + for _ in 0..16 { + if i >= kk { + break; + } + result = result * (n - i); + result = result / (i + 1); + i = i + 1; + } + return result; +} + +/// Binomial coefficient for u64 +pub fn binomial_u64(n: u64, k: u64) -> u64 { + if k > n { + return 0; + } + if k == 0 or k == n { + return 1; + } + + mut kk := k; + if k > n - k { + kk = n - k; + } + + mut result: u64 = 1; + mut i: u64 = 0; + for _ in 0..32 { + if i >= kk { + break; + } + result = result * (n - i); + result = result / (i + 1); + i = i + 1; + } + return result; +} + +// ============================================================================= +// Exponentiation +// ============================================================================= + +/// Integer power (exponentiation by squaring) +pub fn pow_u32(base: u32, exp: u32) -> u32 { + if exp == 0 { + return 1; + } + + mut result: u32 = 1; + mut b := base; + mut e := exp; + + for _ in 0..32 { + if e == 0 { + break; + } + if (e & 1) == 1 { + result = result * b; + } + e = e >> 1; + if e != 0 { + b = b * b; + } + } + return result; +} + +/// Integer power for u64 +pub fn pow_u64(base: u64, exp: u64) -> u64 { + if exp == 0 { + return 1; + } + + mut result: u64 = 1; + mut b := base; + mut e := exp; + + for _ in 0..64 { + if e == 0 { + break; + } + if (e & 1) == 1 { + result = result * b; + } + e = e >> 1; + if e != 0 { + b = b * b; + } + } + return result; +} + +// ============================================================================= +// Distance Functions +// ============================================================================= + +/// Manhattan distance in 2D +pub fn manhattan_i32(x1: i32, y1: i32, x2: i32, y2: i32) -> i32 { + return abs_i32(x2 - x1) + abs_i32(y2 - y1); +} + +/// Chebyshev distance in 2D (max of absolute differences) +pub fn chebyshev_i32(x1: i32, y1: i32, x2: i32, y2: i32) -> i32 { + dx := abs_i32(x2 - x1); + dy := abs_i32(y2 - y1); + if dx > dy { + return dx; + } + return dy; +} diff --git a/exp/zlup/std/math.zlup b/exp/zlup/std/math.zlup new file mode 100644 index 000000000..a6863c1ee --- /dev/null +++ b/exp/zlup/std/math.zlup @@ -0,0 +1,79 @@ +/// Mathematical constants and utilities for Zluppy. +/// Provides commonly used constants for quantum gate rotations. + +// ============================================================================= +// Fundamental Constants +// ============================================================================= + +/// Pi - the ratio of a circle's circumference to its diameter. +/// Commonly used in rotation gates: rz(pi/4), rx(pi/2), etc. +pub pi: a64 = 3.14159265358979323846; + +/// Tau - the ratio of a circle's circumference to its radius (2*pi). +/// Some prefer tau for full rotations: rz(tau/8) = rz(pi/4). +pub tau: a64 = 6.28318530717958647692; + +/// Euler's number - the base of natural logarithms. +/// Used in some advanced quantum algorithms. +pub e: a64 = 2.71828182845904523536; + +/// Square root of 2. +/// Used in normalization: 1/sqrt2 for Hadamard gate. +pub sqrt2: f64 = 1.41421356237309504880; + +/// Reciprocal of square root of 2. +/// Hadamard matrix elements: 1/sqrt(2). +pub inv_sqrt2: f64 = 0.70710678118654752440; + +// ============================================================================= +// Common Angle Fractions (in radians) +// ============================================================================= + +/// Pi divided by 2 (90 degrees). +/// T-gate squared, S-gate: rz(pi/2). +pub pi_2: a64 = 1.57079632679489661923; + +/// Pi divided by 4 (45 degrees). +/// T-gate angle: rz(pi/4). +pub pi_4: a64 = 0.78539816339744830962; + +/// Pi divided by 8 (22.5 degrees). +/// T-gate squared: rz(pi/8). +pub pi_8: a64 = 0.39269908169872415481; + +/// Pi divided by 3 (60 degrees). +pub pi_3: a64 = 1.04719755119659774615; + +/// Pi divided by 6 (30 degrees). +pub pi_6: a64 = 0.52359877559829887308; + +/// Pi divided by 16 (11.25 degrees). +pub pi_16: a64 = 0.19634954084936207740; + +/// 2*Pi divided by 3 (120 degrees). +pub two_pi_3: a64 = 2.09439510239319549230; + +// ============================================================================= +// Negative Angles (for convenience) +// ============================================================================= + +/// Negative pi/2 (-90 degrees). +pub neg_pi_2: a64 = -1.57079632679489661923; + +/// Negative pi/4 (-45 degrees). +pub neg_pi_4: a64 = -0.78539816339744830962; + +/// Negative pi/8 (-22.5 degrees). +pub neg_pi_8: a64 = -0.39269908169872415481; + +// ============================================================================= +// Degree Conversions +// ============================================================================= + +/// Degrees to radians conversion factor. +/// Usage: angle_rad := degrees * deg_to_rad +pub deg_to_rad: f64 = 0.01745329251994329577; + +/// Radians to degrees conversion factor. +/// Usage: angle_deg := radians * rad_to_deg +pub rad_to_deg: f64 = 57.29577951308232087680; diff --git a/exp/zlup/std/qec.zlup b/exp/zlup/std/qec.zlup new file mode 100644 index 000000000..03ef5d700 --- /dev/null +++ b/exp/zlup/std/qec.zlup @@ -0,0 +1,340 @@ +/// Quantum Error Correction utilities for Zluppy. +/// Data structures and algorithms commonly used in QEC decoders. + +/// Error set for QEC operations. +pub OverflowError := error { Overflow }; + +/// Union-Find (Disjoint Set Union) data structure. +/// Essential for MWPM (Minimum Weight Perfect Matching) decoders. +/// +/// Example: +/// ```zlup +/// uf: UnionFind(256) = .{}; +/// uf.union(0, 1); +/// uf.union(1, 2); +/// root := uf.find(2); // Returns same root as find(0) and find(1) +/// ``` +pub UnionFind := fn(comptime capacity: usize) -> type { + struct { + /// Parent pointers (parent[i] == i means i is a root) + parent: [capacity]usize = undefined, + /// Rank for union-by-rank optimization + rank: [capacity]usize = undefined, + /// Whether the structure has been initialized + initialized: bool = false, + + /// Initialize all elements as separate sets. + pub fn init(&mut self) -> unit { + inline for i in 0..capacity { + self.parent[i] = i; + self.rank[i] = 0; + } + self.initialized = true; + return unit; + } + + /// Find the representative (root) of the set containing x. + /// Uses path compression for efficiency. + pub fn find(&mut self, x: usize) -> usize { + if (self.parent[x] != x) { + self.parent[x] = self.find(self.parent[x]); + } + return self.parent[x]; + } + + /// Unite the sets containing x and y. + /// Uses union-by-rank for efficiency. + /// Returns true if the sets were different (and thus merged). + pub fn union(&mut self, x: usize, y: usize) -> bool { + root_x := self.find(x); + root_y := self.find(y); + + if (root_x == root_y) { + return false; // Already in same set + } + + // Union by rank + if (self.rank[root_x] < self.rank[root_y]) { + self.parent[root_x] = root_y; + } else if (self.rank[root_x] > self.rank[root_y]) { + self.parent[root_y] = root_x; + } else { + self.parent[root_y] = root_x; + self.rank[root_x] += 1; + } + return true; + } + + /// Check if x and y are in the same set. + pub fn connected(&mut self, x: usize, y: usize) -> bool { + return self.find(x) == self.find(y); + } + + /// Reset element x to its own set. + pub fn reset(&mut self, x: usize) -> unit { + self.parent[x] = x; + self.rank[x] = 0; + return unit; + } + + /// Reset all elements to separate sets. + pub fn reset_all(&mut self) -> unit { + self.init(); + return unit; + } + } +}; + +/// Syndrome storage for stabilizer codes. +/// Stores syndrome bits from measurement rounds. +/// +/// Example: +/// ```zlup +/// syndrome: SyndromeBuffer(16, 10) = .{}; // 16 ancillas, 10 rounds +/// syndrome.set(0, 0, true); // Ancilla 0, round 0 +/// if syndrome.get(0, 0) { ... } +/// ``` +pub SyndromeBuffer := fn(comptime num_ancillas: usize, comptime max_rounds: usize) -> type { + struct { + /// Syndrome data: data[round][ancilla] + data: [max_rounds][num_ancillas]bool = undefined, + /// Number of rounds recorded + rounds_recorded: usize = 0, + + /// Clear all syndrome data. + pub fn clear(&mut self) -> unit { + self.rounds_recorded = 0; + return unit; + } + + /// Set syndrome bit for given ancilla and round. + pub fn set(&mut self, ancilla: usize, round: usize, value: bool) -> unit { + self.data[round][ancilla] = value; + if (round >= self.rounds_recorded) { + self.rounds_recorded = round + 1; + } + return unit; + } + + /// Get syndrome bit for given ancilla and round. + pub fn get(&mut self, ancilla: usize, round: usize) -> bool { + return self.data[round][ancilla]; + } + + /// Record a full round of syndrome measurements. + pub fn record_round(&mut self, syndromes: [num_ancillas]bool) -> OverflowError!void { + if (self.rounds_recorded >= max_rounds) { + return error.Overflow; + } + self.data[self.rounds_recorded] = syndromes; + self.rounds_recorded += 1; + return unit; + } + + /// Get the number of rounds recorded. + pub fn num_rounds(&mut self) -> usize { + return self.rounds_recorded; + } + + /// Check if any syndrome was triggered in a round. + pub fn has_error(&mut self, round: usize) -> bool { + inline for ancilla in 0..num_ancillas { + if (self.data[round][ancilla]) { + return true; + } + } + return false; + } + + /// Count number of triggered syndromes in a round. + pub fn count_errors(&mut self, round: usize) -> usize { + mut count: usize = 0; + inline for ancilla in 0..num_ancillas { + if (self.data[round][ancilla]) { + count += 1; + } + } + return count; + } + } +}; + +/// Lookup table decoder for small codes. +/// Maps syndromes to corrections using a precomputed table. +/// +/// Example: +/// ```zlup +/// decoder: LookupDecoder(u8, u8, 256) = .{}; +/// decoder.add_entry(0b101, 0b001); // syndrome -> correction +/// if correction := decoder.decode(0b101) { ... } +/// ``` +pub LookupDecoder := fn(comptime SyndromeType: type, comptime CorrectionType: type, comptime table_size: usize) -> type { + struct { + /// Syndrome values + syndromes: [table_size]SyndromeType = undefined, + /// Corresponding corrections + corrections: [table_size]CorrectionType = undefined, + /// Number of entries in the table + num_entries: usize = 0, + + /// Add an entry to the lookup table. + pub fn add_entry(&mut self, syndrome: SyndromeType, correction: CorrectionType) -> OverflowError!void { + if (self.num_entries >= table_size) { + return error.Overflow; + } + self.syndromes[self.num_entries] = syndrome; + self.corrections[self.num_entries] = correction; + self.num_entries += 1; + return unit; + } + + /// Look up the correction for a syndrome. + /// Returns none if the syndrome is not in the table. + pub fn decode(&mut self, syndrome: SyndromeType) -> ?CorrectionType { + // Linear search with bounded iteration (table_size is comptime) + inline for i in 0..table_size { + if (i >= self.num_entries) { + break; + } + if (self.syndromes[i] == syndrome) { + return self.corrections[i]; + } + } + return none; + } + + /// Clear all entries. + pub fn clear(&mut self) -> unit { + self.num_entries = 0; + return unit; + } + } +}; + +/// Pauli frame tracker for tracking Pauli corrections. +/// Used in fault-tolerant protocols with deferred corrections. +/// +/// Example: +/// ```zlup +/// frame: PauliFrame(16) = .{}; // 16 logical qubits +/// frame.apply_x(0); // Track X correction on qubit 0 +/// frame.apply_z(0); // Track Z correction on qubit 0 +/// if frame.has_x(0) { ... } +/// ``` +pub PauliFrame := fn(comptime num_qubits: usize) -> type { + struct { + /// X corrections (bit i = 1 means X correction pending on qubit i) + x_frame: [num_qubits]bool = undefined, + /// Z corrections (bit i = 1 means Z correction pending on qubit i) + z_frame: [num_qubits]bool = undefined, + + /// Initialize with no corrections. + pub fn init(&mut self) -> unit { + inline for i in 0..num_qubits { + self.x_frame[i] = false; + self.z_frame[i] = false; + } + return unit; + } + + /// Apply X correction to qubit i (toggles the X frame). + pub fn apply_x(&mut self, i: usize) -> unit { + self.x_frame[i] = !self.x_frame[i]; + return unit; + } + + /// Apply Z correction to qubit i (toggles the Z frame). + pub fn apply_z(&mut self, i: usize) -> unit { + self.z_frame[i] = !self.z_frame[i]; + return unit; + } + + /// Apply Y correction to qubit i (toggles both X and Z frames). + pub fn apply_y(&mut self, i: usize) -> unit { + self.x_frame[i] = !self.x_frame[i]; + self.z_frame[i] = !self.z_frame[i]; + return unit; + } + + /// Check if X correction is pending on qubit i. + pub fn has_x(&mut self, i: usize) -> bool { + return self.x_frame[i]; + } + + /// Check if Z correction is pending on qubit i. + pub fn has_z(&mut self, i: usize) -> bool { + return self.z_frame[i]; + } + + /// Clear all corrections. + pub fn clear(&mut self) -> unit { + self.init(); + return unit; + } + + /// Get the total weight of pending corrections. + pub fn weight(&mut self) -> usize { + mut w: usize = 0; + inline for i in 0..num_qubits { + if (self.x_frame[i]) { + w += 1; + } + if (self.z_frame[i]) { + w += 1; + } + } + return w; + } + } +}; + +/// Sparse graph representation for decoder graphs. +/// Useful for representing syndrome graphs in MWPM decoders. +/// +/// Example: +/// ```zlup +/// graph: SparseGraph(64, 256) = .{}; // 64 nodes, 256 edges max +/// try graph.add_edge(0, 1, 10); // Edge from 0 to 1 with weight 10 +/// ``` +/// Edge structure for sparse graphs. +pub Edge := struct { + from: usize, + to: usize, + weight: i32, +}; + +pub SparseGraph := fn(comptime max_nodes: usize, comptime max_edges: usize) -> type { + struct { + /// Edge list + edges: [max_edges]Edge = undefined, + /// Number of edges + num_edges: usize = 0, + /// Adjacency list start indices for each node + adj_start: [max_nodes + 1]usize = undefined, + /// Whether adjacency list is built + adj_built: bool = false, + + /// Add an edge to the graph. + pub fn add_edge(&mut self, from_node: usize, to_node: usize, weight: i32) -> OverflowError!void { + if (self.num_edges >= max_edges) { + return error.Overflow; + } + self.edges[self.num_edges] = .{ from: from_node, to: to_node, weight: weight }; + self.num_edges += 1; + self.adj_built = false; + return unit; + } + + /// Get the number of edges. + pub fn edge_count(&mut self) -> usize { + return self.num_edges; + } + + /// Clear all edges. + pub fn clear(&mut self) -> unit { + self.num_edges = 0; + self.adj_built = false; + return unit; + } + } +}; diff --git a/exp/zlup/std/qec/decoder.zlp b/exp/zlup/std/qec/decoder.zlp new file mode 100644 index 000000000..747eb3065 --- /dev/null +++ b/exp/zlup/std/qec/decoder.zlp @@ -0,0 +1,151 @@ +/// Standard library: Simple decoders for small codes +/// +/// For production decoders (MWPM, Union-Find, ML), use Rust via FFI. +/// These are for small codes, testing, and educational purposes. + +// ============================================================================= +// 3-Qubit Bit-Flip Code +// ============================================================================= + +/// Decode 3-qubit bit-flip code +/// Syndrome (2 bits) -> which qubit to flip (0, 1, 2, or 3 for none) +pub fn bit_flip_3_decode(syndrome: u8) -> u8 { + s := syndrome & 3; + if s == 0 { + return 3; // No error + } + if s == 1 { + return 2; // Flip qubit 2 + } + if s == 2 { + return 0; // Flip qubit 0 + } + return 1; // s == 3: Flip qubit 1 +} + +/// Get correction bitmask for 3-qubit bit-flip code +/// Returns a u8 with the bit set for the qubit to correct +pub fn bit_flip_3_correction(syndrome: u8) -> u8 { + s := syndrome & 3; + if s == 0 { + return 0; // No correction + } + if s == 1 { + return 4; // Correct qubit 2 (bit 2) + } + if s == 2 { + return 1; // Correct qubit 0 (bit 0) + } + return 2; // s == 3: Correct qubit 1 (bit 1) +} + +// ============================================================================= +// Majority Vote +// ============================================================================= + +/// Majority vote decoder +/// Returns 1 if more than half the bits in syndrome are set +pub fn majority_vote(syndrome: u64, num_bits: u64) -> u64 { + mut count: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if s == 0 { + break; + } + if (s & 1) == 1 { + count = count + 1; + } + s = s >> 1; + } + + threshold := (num_bits / 2) + 1; + if count >= threshold { + return 1; + } + return 0; +} + +// ============================================================================= +// Parity Check +// ============================================================================= + +/// Compute single syndrome bit from error and parity check +/// check is a bitmask indicating which qubits participate +pub fn compute_check(err: u64, check: u64) -> u64 { + overlap := err & check; + // Compute parity + mut p := overlap; + p = p ^ (p >> 32); + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Compute full syndrome from error using check matrix +/// checks is an array of bitmasks (one per syndrome bit) +/// This version handles up to 8 checks +pub fn compute_syndrome_8(err: u64, c0: u64, c1: u64, c2: u64, c3: u64, c4: u64, c5: u64, c6: u64, c7: u64, num_checks: u64) -> u64 { + mut syndrome: u64 = 0; + + if num_checks > 0 { + syndrome = syndrome | (compute_check(err, c0) << 0); + } + if num_checks > 1 { + syndrome = syndrome | (compute_check(err, c1) << 1); + } + if num_checks > 2 { + syndrome = syndrome | (compute_check(err, c2) << 2); + } + if num_checks > 3 { + syndrome = syndrome | (compute_check(err, c3) << 3); + } + if num_checks > 4 { + syndrome = syndrome | (compute_check(err, c4) << 4); + } + if num_checks > 5 { + syndrome = syndrome | (compute_check(err, c5) << 5); + } + if num_checks > 6 { + syndrome = syndrome | (compute_check(err, c6) << 6); + } + if num_checks > 7 { + syndrome = syndrome | (compute_check(err, c7) << 7); + } + + return syndrome; +} + +// ============================================================================= +// Simple Lookup (8-bit syndrome) +// ============================================================================= + +/// Lookup table decode for 4-entry table (2-bit syndrome) +pub fn lookup_2bit(syndrome: u8, t0: u64, t1: u64, t2: u64, t3: u64) -> u64 { + s := syndrome & 3; + if s == 0 { + return t0; + } + if s == 1 { + return t1; + } + if s == 2 { + return t2; + } + return t3; +} + +/// Lookup table decode for 8-entry table (3-bit syndrome) +pub fn lookup_3bit(syndrome: u8, t0: u64, t1: u64, t2: u64, t3: u64, t4: u64, t5: u64, t6: u64, t7: u64) -> u64 { + s := syndrome & 7; + if s == 0 { return t0; } + if s == 1 { return t1; } + if s == 2 { return t2; } + if s == 3 { return t3; } + if s == 4 { return t4; } + if s == 5 { return t5; } + if s == 6 { return t6; } + return t7; +} diff --git a/exp/zlup/std/qec/errors.zlp b/exp/zlup/std/qec/errors.zlp new file mode 100644 index 000000000..fedfafd74 --- /dev/null +++ b/exp/zlup/std/qec/errors.zlp @@ -0,0 +1,210 @@ +/// Standard library: Error tracking utilities for QEC +/// +/// Utilities for tracking errors, counting faults, and managing error budgets. +/// All operations are explicit and bounded (no hidden allocations). + +// ============================================================================= +// Error Counters +// ============================================================================= + +/// Count errors in a syndrome history (packed u64 array style) +/// Each bit position tracks one stabilizer over time +/// Returns count of how many times syndrome bit was set +pub fn count_flips_at_position(history: u64, pos: u64) -> u64 { + if pos >= 64 { + return 0; + } + return (history >> pos) & 1; +} + +/// Count total number of error events (defects) across all positions +pub fn total_defects(syndrome: u64) -> u64 { + mut count: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if s == 0 { + break; + } + count = count + (s & 1); + s = s >> 1; + } + return count; +} + +/// Check if error count exceeds threshold +pub fn exceeds_threshold(error_count: u64, threshold: u64) -> bool { + return error_count > threshold; +} + +// ============================================================================= +// Error Rate Estimation +// ============================================================================= + +/// Estimate error rate from counts (returns permil - per 1000) +/// Avoids floating point: returns (errors * 1000) / total +pub fn error_rate_permil(errors: u64, total: u64) -> u64 { + if total == 0 { + return 0; + } + return (errors * 1000) / total; +} + +/// Estimate error rate (returns percent * 100 for precision) +/// Example: 3.5% returns 350 +pub fn error_rate_percent_x100(errors: u64, total: u64) -> u64 { + if total == 0 { + return 0; + } + return (errors * 10000) / total; +} + +// ============================================================================= +// Error Budget Tracking +// ============================================================================= + +/// Check if within error budget +/// budget_permil is the acceptable error rate in permil (e.g., 1 = 0.1%) +pub fn within_budget(errors: u64, total: u64, budget_permil: u64) -> bool { + if total == 0 { + return true; + } + actual := (errors * 1000) / total; + return actual <= budget_permil; +} + +/// Calculate remaining error budget before threshold +/// Returns how many more errors can occur before exceeding budget_permil +pub fn remaining_budget(errors: u64, total: u64, budget_permil: u64) -> u64 { + if total == 0 { + return 0; + } + // max_errors = (budget_permil * total) / 1000 + max_errors := (budget_permil * total) / 1000; + if errors >= max_errors { + return 0; + } + return max_errors - errors; +} + +// ============================================================================= +// Syndrome Difference Tracking +// ============================================================================= + +/// Compute syndrome change between two rounds +pub fn syndrome_delta(prev: u64, curr: u64) -> u64 { + return prev ^ curr; +} + +/// Count number of stabilizers that flipped between rounds +pub fn count_flipped(prev: u64, curr: u64) -> u64 { + delta := prev ^ curr; + mut count: u64 = 0; + mut d := delta; + for _ in 0..64 { + if d == 0 { + break; + } + count = count + (d & 1); + d = d >> 1; + } + return count; +} + +/// Check if syndrome is stable (no change) +pub fn is_stable(prev: u64, curr: u64) -> bool { + return prev == curr; +} + +// ============================================================================= +// Sliding Window Statistics +// ============================================================================= + +/// Update sliding window error count (simple, uses XOR history) +/// Returns new window with oldest bit removed and newest added +/// window: current window state (each bit = error in that round) +/// newest: 1 if error in current round, 0 otherwise +/// window_size: size of window (bits to track) +pub fn slide_window(window: u64, newest: u64, window_size: u64) -> u64 { + if window_size == 0 or window_size > 64 { + return window; + } + // Shift left to make room for new bit, mask to window size + one: u64 = 1; + mask := (one << window_size) - 1; + return ((window << 1) | (newest & 1)) & mask; +} + +/// Count errors in sliding window +pub fn window_error_count(window: u64) -> u64 { + mut count: u64 = 0; + mut w := window; + for _ in 0..64 { + if w == 0 { + break; + } + count = count + (w & 1); + w = w >> 1; + } + return count; +} + +// ============================================================================= +// Error Classification +// ============================================================================= + +/// Classify error weight (for debugging/analysis) +/// Returns: 0=none, 1=single, 2=double, 3=triple+ +pub fn classify_weight(syndrome: u64) -> u64 { + count := total_defects(syndrome); + if count == 0 { + return 0; + } + if count == 1 { + return 1; + } + if count == 2 { + return 2; + } + return 3; +} + +/// Check if error is likely correctable (weight <= t for distance 2t+1 code) +pub fn is_likely_correctable(syndrome: u64, t: u64) -> bool { + weight := total_defects(syndrome); + return weight <= t; +} + +// ============================================================================= +// Fault Flags +// ============================================================================= + +/// Pack multiple fault flags into a single u64 +/// Each bit represents one type of fault condition +pub FLAG_NONE: u64 = 0; +pub FLAG_X_ERROR: u64 = 1; +pub FLAG_Z_ERROR: u64 = 2; +pub FLAG_Y_ERROR: u64 = 3; // X | Z +pub FLAG_MEASUREMENT_ERROR: u64 = 4; +pub FLAG_LEAKAGE: u64 = 8; +pub FLAG_TIMING_ERROR: u64 = 16; +pub FLAG_THRESHOLD_EXCEEDED: u64 = 32; + +/// Set a fault flag +pub fn set_flag(flags: u64, flag: u64) -> u64 { + return flags | flag; +} + +/// Clear a fault flag +pub fn clear_flag(flags: u64, flag: u64) -> u64 { + return flags & (~flag); +} + +/// Check if flag is set +pub fn has_flag(flags: u64, flag: u64) -> bool { + return (flags & flag) != 0; +} + +/// Check if any error flag is set (X, Z, or Y) +pub fn has_pauli_error(flags: u64) -> bool { + return (flags & FLAG_Y_ERROR) != 0; +} diff --git a/exp/zlup/std/qec/pauli.zlp b/exp/zlup/std/qec/pauli.zlp new file mode 100644 index 000000000..0dc4e934e --- /dev/null +++ b/exp/zlup/std/qec/pauli.zlp @@ -0,0 +1,175 @@ +/// Standard library: Pauli frame tracking for QEC +/// +/// Pauli frames are represented as two u64 bitmasks (x_frame, z_frame) +/// supporting up to 64 qidxs. + +// ============================================================================= +// Pauli Frame Operations +// ============================================================================= + +/// Apply X correction to frame (toggle bit in x_frame) +pub fn frame_apply_x(x_frame: u64, qidx: u64) -> u64 { + if qidx >= 64 { + return x_frame; + } + mask: u64 = 1 << qidx; + return x_frame ^ mask; +} + +/// Apply Z correction to frame (toggle bit in z_frame) +pub fn frame_apply_z(z_frame: u64, qidx: u64) -> u64 { + if qidx >= 64 { + return z_frame; + } + mask: u64 = 1 << qidx; + return z_frame ^ mask; +} + +/// Check if qidx has X correction +pub fn frame_has_x(x_frame: u64, qidx: u64) -> bool { + if qidx >= 64 { + return false; + } + return ((x_frame >> qidx) & 1) == 1; +} + +/// Check if qidx has Z correction +pub fn frame_has_z(z_frame: u64, qidx: u64) -> bool { + if qidx >= 64 { + return false; + } + return ((z_frame >> qidx) & 1) == 1; +} + +// ============================================================================= +// Gate Propagation (returns new frame values) +// ============================================================================= + +/// Propagate X frame through CNOT: X on control propagates to target +/// Returns the new X frame +pub fn propagate_cx_x(x_frame: u64, control: u64, target: u64) -> u64 { + if control >= 64 or target >= 64 { + return x_frame; + } + // X on control propagates to target + if ((x_frame >> control) & 1) == 1 { + mask: u64 = 1 << target; + return x_frame ^ mask; + } + return x_frame; +} + +/// Propagate Z frame through CNOT: Z on target propagates to control +/// Returns the new Z frame +pub fn propagate_cx_z(z_frame: u64, control: u64, target: u64) -> u64 { + if control >= 64 or target >= 64 { + return z_frame; + } + // Z on target propagates to control + if ((z_frame >> target) & 1) == 1 { + mask: u64 = 1 << control; + return z_frame ^ mask; + } + return z_frame; +} + +/// Propagate X frame through CZ: X on qa propagates Z to qb +/// Returns the new Z frame (X frame unchanged by CZ) +pub fn propagate_cz_to_z(x_frame: u64, z_frame: u64, qa: u64, qb: u64) -> u64 { + if qa >= 64 or qb >= 64 { + return z_frame; + } + mut new_z := z_frame; + // X on a propagates Z to b + if ((x_frame >> qa) & 1) == 1 { + mask: u64 = 1 << qb; + new_z = new_z ^ mask; + } + // X on b propagates Z to a + if ((x_frame >> qb) & 1) == 1 { + mask: u64 = 1 << qa; + new_z = new_z ^ mask; + } + return new_z; +} + +/// Swap X and Z for a qidx (Hadamard propagation) +/// Returns (new_x_frame, new_z_frame) packed as (x << 64) | z... +/// Actually, let's return just the swapped values for one qidx +pub fn propagate_h_x(x_frame: u64, z_frame: u64, qidx: u64) -> u64 { + // After H, new X = old Z + if qidx >= 64 { + return x_frame; + } + has_z := ((z_frame >> qidx) & 1) == 1; + mask: u64 = 1 << qidx; + // Clear old X bit + mut new_x := x_frame & (~mask); + // Set from old Z + if has_z { + new_x = new_x | mask; + } + return new_x; +} + +pub fn propagate_h_z(x_frame: u64, z_frame: u64, qidx: u64) -> u64 { + // After H, new Z = old X + if qidx >= 64 { + return z_frame; + } + has_x := ((x_frame >> qidx) & 1) == 1; + mask: u64 = 1 << qidx; + // Clear old Z bit + mut new_z := z_frame & (~mask); + // Set from old X + if has_x { + new_z = new_z | mask; + } + return new_z; +} + +/// S gate propagation: X gains Z +pub fn propagate_s_z(x_frame: u64, z_frame: u64, qidx: u64) -> u64 { + if qidx >= 64 { + return z_frame; + } + // X on qidx gains Z + if ((x_frame >> qidx) & 1) == 1 { + mask: u64 = 1 << qidx; + return z_frame ^ mask; + } + return z_frame; +} + +// ============================================================================= +// Pauli Multiplication +// ============================================================================= + +/// Pauli operators: 0=I, 1=X, 2=Y, 3=Z +pub PAULI_I: u8 = 0; +pub PAULI_X: u8 = 1; +pub PAULI_Y: u8 = 2; +pub PAULI_Z: u8 = 3; + +/// Multiply two Pauli operators (ignoring phase) +pub fn pauli_mul(a: u8, b: u8) -> u8 { + if a == 0 { + return b; + } + if b == 0 { + return a; + } + if a == b { + return 0; + } + // Different non-identity: result is the third + return 6 - a - b; +} + +/// Check if two Paulis commute +pub fn pauli_commute(a: u8, b: u8) -> bool { + if a == 0 or b == 0 { + return true; + } + return a == b; +} diff --git a/exp/zlup/std/qec/syndrome.zlp b/exp/zlup/std/qec/syndrome.zlp new file mode 100644 index 000000000..6697ee028 --- /dev/null +++ b/exp/zlup/std/qec/syndrome.zlp @@ -0,0 +1,138 @@ +/// Standard library: Syndrome utilities for QEC +/// +/// Provides functions for syndrome processing and analysis. + +// ============================================================================= +// Syndrome Weight and Parity +// ============================================================================= + +/// Count the number of defects (set bits) in a syndrome +pub fn syndrome_weight(syndrome: u64) -> u64 { + mut count: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if s == 0 { + break; + } + if (s & 1) == 1 { + count = count + 1; + } + s = s >> 1; + } + return count; +} + +/// Compute the XOR of two syndromes (difference) +pub fn syndrome_diff(a: u64, b: u64) -> u64 { + return a ^ b; +} + +/// Check if syndrome is trivial (no errors) +pub fn is_trivial(syndrome: u64) -> bool { + return syndrome == 0; +} + +/// Compute parity of syndrome +pub fn syndrome_parity(syndrome: u64) -> u64 { + mut p := syndrome; + p = p ^ (p >> 32); + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +// ============================================================================= +// Defect Position Queries +// ============================================================================= + +/// Find position of first defect (lowest set bit) +/// Returns 64 if no defects +pub fn first_defect(syndrome: u64) -> u64 { + if syndrome == 0 { + return 64; + } + mut pos: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if (s & 1) == 1 { + return pos; + } + s = s >> 1; + pos = pos + 1; + } + return 64; +} + +/// Find position of last defect (highest set bit) +/// Returns 64 if no defects +pub fn last_defect(syndrome: u64) -> u64 { + if syndrome == 0 { + return 64; + } + mut last: u64 = 64; + mut pos: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if s == 0 { + break; + } + if (s & 1) == 1 { + last = pos; + } + s = s >> 1; + pos = pos + 1; + } + return last; +} + +// ============================================================================= +// Syndrome Bit Manipulation +// ============================================================================= + +/// Clear the first defect (lowest set bit) +pub fn clear_first(syndrome: u64) -> u64 { + return syndrome & (syndrome - 1); +} + +/// Isolate the first defect (return only lowest set bit) +pub fn isolate_first(syndrome: u64) -> u64 { + return syndrome & (~syndrome + 1); +} + +/// Get bit at position +pub fn get_bit(syndrome: u64, pos: u64) -> u64 { + if pos >= 64 { + return 0; + } + return (syndrome >> pos) & 1; +} + +/// Set bit at position +pub fn set_bit(syndrome: u64, pos: u64) -> u64 { + if pos >= 64 { + return syndrome; + } + mask: u64 = 1 << pos; + return syndrome | mask; +} + +/// Clear bit at position +pub fn clear_bit(syndrome: u64, pos: u64) -> u64 { + if pos >= 64 { + return syndrome; + } + mask: u64 = 1 << pos; + return syndrome & (~mask); +} + +/// Toggle bit at position +pub fn toggle_bit(syndrome: u64, pos: u64) -> u64 { + if pos >= 64 { + return syndrome; + } + mask: u64 = 1 << pos; + return syndrome ^ mask; +} diff --git a/exp/zlup/std/std.zlp b/exp/zlup/std/std.zlp new file mode 100644 index 000000000..719fc2915 --- /dev/null +++ b/exp/zlup/std/std.zlp @@ -0,0 +1,88 @@ +/// Zlup Standard Library +/// +/// Simple, explicit utilities for quantum programming. +/// No magic, no hidden allocations, bounded everything. +/// +/// Usage: +/// std := @import("std"); // imports this file (std/std.zlp) +/// bits := @import("bits.zlp"); // relative import within std/ +/// +/// Type-namespaced constants (preferred): +/// std.f64.pi // pi as f64 (3.14159...) +/// std.a64.quarter_turn // 1/4 turn as a64 angle +/// std.a64.t_angle // T-gate angle (1/8 turn) +/// +/// Available modules: +/// std/f64.zlp - f64 constants (pi, tau, e, sqrt2, etc.) +/// std/a64.zlp - a64 angle constants (quarter_turn, t_angle, etc.) +/// std/bits.zlp - bit operations (popcount, parity, shifts) +/// std/math.zlp - math functions (gcd, lcm, isqrt, log2, factorial, etc.) +/// std/algorithm.zlp - algorithms (search, sort, min/max, hamming distance) +/// std/qec/ - quantum error correction +/// pauli.zlp - Pauli frame tracking +/// syndrome.zlp - syndrome utilities +/// decoder.zlp - simple decoders for small codes +/// errors.zlp - error tracking and statistics + +// ============================================================================= +// Type-Namespaced Modules +// ============================================================================= + +/// f64 constants: std.f64.pi, std.f64.tau, std.f64.e, etc. +pub f64 := @import("f64.zlp"); + +/// a64 angle constants: std.a64.quarter_turn, std.a64.t_angle, etc. +pub a64 := @import("a64.zlp"); + +// ============================================================================= +// Bit Operations (core subset inlined for convenience) +// ============================================================================= + +/// Count set bits in u8 +pub fn popcount_u8(x: u8) -> u8 { + mut count: u8 = 0; + mut n := x; + for _ in 0..8 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Count set bits in u64 +pub fn popcount_u64(x: u64) -> u64 { + mut count: u64 = 0; + mut n := x; + for _ in 0..64 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Parity of u8 +pub fn parity_u8(x: u8) -> u8 { + mut p := x; + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Parity of u64 +pub fn parity_u64(x: u64) -> u64 { + mut p := x; + p = p ^ (p >> 32); + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} diff --git a/exp/zlup/std/std.zlup b/exp/zlup/std/std.zlup new file mode 100644 index 000000000..28f53ad2c --- /dev/null +++ b/exp/zlup/std/std.zlup @@ -0,0 +1,51 @@ +/// Zluppy Standard Library +/// +/// The standard library provides common data structures and utilities +/// for quantum programming with NASA Power of 10 compliance. +/// +/// All containers have bounded capacity specified at compile time. +/// +/// Modules: +/// - math: Mathematical constants (pi, tau, e, sqrt2, angle fractions) +/// - bits: Bitwise utilities (popcount, parity, bit extraction) +/// - containers: Stack, Queue, Deque, PriorityQueue +/// - qec: UnionFind, SyndromeBuffer, LookupDecoder, PauliFrame, SparseGraph + +pub math := @import("math.zlup"); +pub bits := @import("bits.zlup"); +pub containers := @import("containers.zlup"); +pub qec := @import("qec.zlup"); + +// Re-export mathematical constants for convenience +pub pi := math.pi; +pub tau := math.tau; +pub e := math.e; +pub sqrt2 := math.sqrt2; +pub inv_sqrt2 := math.inv_sqrt2; +pub pi_2 := math.pi_2; +pub pi_4 := math.pi_4; +pub pi_8 := math.pi_8; +pub deg_to_rad := math.deg_to_rad; +pub rad_to_deg := math.rad_to_deg; + +// Re-export bitwise utilities for convenience +pub popcount_u8 := bits.popcount_u8; +pub popcount_u16 := bits.popcount_u16; +pub popcount_u32 := bits.popcount_u32; +pub popcount_u64 := bits.popcount_u64; +pub parity_u8 := bits.parity_u8; +pub parity_u16 := bits.parity_u16; +pub parity_u32 := bits.parity_u32; +pub parity_u64 := bits.parity_u64; + +// Re-export commonly used types for convenience +pub Stack := containers.Stack; +pub Queue := containers.Queue; +pub Deque := containers.Deque; +pub PriorityQueue := containers.PriorityQueue; + +pub UnionFind := qec.UnionFind; +pub SyndromeBuffer := qec.SyndromeBuffer; +pub LookupDecoder := qec.LookupDecoder; +pub PauliFrame := qec.PauliFrame; +pub SparseGraph := qec.SparseGraph; diff --git a/exp/zlup/tests/cli.rs b/exp/zlup/tests/cli.rs new file mode 100644 index 000000000..3b0277659 --- /dev/null +++ b/exp/zlup/tests/cli.rs @@ -0,0 +1,1136 @@ +//! CLI integration tests for Zlup. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +/// Get the path to the zlup binary. +fn zlup_bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_zlup")) +} + +// ============================================================================= +// Help and Version +// ============================================================================= + +#[test] +fn test_help() { + let output = zlup_bin().arg("--help").output().expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Zluppy")); + assert!(stdout.contains("compile")); + assert!(stdout.contains("check")); + assert!(stdout.contains("parse")); + assert!(stdout.contains("analyze")); +} + +#[test] +fn test_version() { + let output = zlup_bin().arg("--version").output().expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("zlup")); +} + +#[test] +fn test_compile_help() { + let output = zlup_bin() + .args(["compile", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("target")); + assert!(stdout.contains("slr")); +} + +#[test] +fn test_check_help() { + let output = zlup_bin() + .args(["check", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("strict")); +} + +// ============================================================================= +// Compile Command +// ============================================================================= + +#[test] +fn test_compile_stdin_bell_state() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success(), "compile failed: {:?}", output); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Verify JSON structure + let json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON output"); + + assert_eq!(json["type"], "Program"); + assert_eq!(json["name"], "main"); + assert!(json["allocator"].is_object()); + assert_eq!(json["allocator"]["name"], "q"); + assert_eq!(json["allocator"]["capacity"], 2); + + // Check body has 2 gates + let body = json["body"].as_array().expect("body should be array"); + assert_eq!(body.len(), 2); + assert_eq!(body[0]["gate"], "H"); + assert_eq!(body[1]["gate"], "CX"); +} + +#[test] +fn test_compile_compact() { + let source = "fn main() -> unit { mut q := qalloc(1); h q[0]; return unit; }"; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "--compact", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Compact output should not have newlines (except possibly at end) + let trimmed = stdout.trim(); + assert!( + !trimmed.contains("\n "), + "compact output should not be pretty-printed" + ); + + // But should still be valid JSON + let _json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); +} + +#[test] +fn test_compile_rotation_gate() { + let source = r#"fn main() -> unit { + mut q := qalloc(1); + rz(1.57) q[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); + + let body = json["body"].as_array().expect("body should be array"); + assert_eq!(body.len(), 1); + assert_eq!(body[0]["gate"], "RZ"); + assert!(!body[0]["params"].as_array().unwrap().is_empty()); +} + +// ============================================================================= +// Check Command +// ============================================================================= + +#[test] +fn test_check_valid_program() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["check", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("OK")); +} + +#[test] +fn test_check_strict_mode() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["check", "-", "--strict"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + // In strict mode, gates on unprepared qubits fail + // But our current implementation doesn't track through function calls + // so this may or may not fail depending on implementation details + // For now, just verify it runs without crashing + let _ = output.status; +} + +// ============================================================================= +// Parse Command +// ============================================================================= + +#[test] +fn test_parse_debug_format() { + let source = "fn main() -> unit { mut q := qalloc(1); return unit; }"; + + let mut child = zlup_bin() + .args(["parse", "-", "--format", "debug"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Program")); + assert!(stdout.contains("FnDecl")); +} + +// ============================================================================= +// Error Handling +// ============================================================================= + +#[test] +fn test_compile_parse_error() { + let source = "fn main() -> unit { h q[0 }"; // Missing closing bracket + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(!output.status.success(), "should fail on parse error"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("parse error") || stderr.contains("expected"), + "should contain error message" + ); +} + +#[test] +fn test_check_parse_error() { + let source = "fn main( unit { }"; // Missing closing paren in params + + let mut child = zlup_bin() + .args(["check", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(!output.status.success(), "should fail on parse error"); +} + +#[test] +fn test_compile_file_not_found() { + let output = zlup_bin() + .args(["compile", "nonexistent_file_12345.zlp"]) + .output() + .expect("failed to run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("read") || stderr.contains("No such file"), + "should report file not found" + ); +} + +// ============================================================================= +// Complex Programs +// ============================================================================= + +#[test] +fn test_compile_child_allocator() { + let source = r#"fn main() -> unit { + mut base := qalloc(4); + mut q := base.child(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); + + // Should have declarations for both allocators + let decls = json["declarations"].as_array().expect("declarations array"); + assert!(decls.len() >= 2, "should have at least 2 declarations"); +} + +#[test] +fn test_compile_conditional() { + let source = r#"fn main() -> unit { + mut q := qalloc(1); + x := 1; + if (x == 1) { + h q[0]; + } + return unit; +}"#; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); + + // Should have if statement in body + let body = json["body"].as_array().expect("body array"); + let has_if = body.iter().any(|stmt| stmt["type"] == "IfStmt"); + assert!(has_if, "should have if statement in body"); +} + +// ============================================================================= +// Init Command +// ============================================================================= + +/// Create a unique temp directory for testing +fn temp_dir(test_name: &str) -> PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "zlup-test-{}-{}-{}", + test_name, + std::process::id(), + timestamp + )); + fs::create_dir_all(&dir).expect("failed to create temp dir"); + dir +} + +/// Clean up a temp directory +fn cleanup_temp_dir(dir: &PathBuf) { + if dir.exists() { + let _ = fs::remove_dir_all(dir); + } +} + +#[test] +fn test_init_help() { + let output = zlup_bin() + .args(["init", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Initialize")); + assert!(stdout.contains("NAME")); +} + +#[test] +fn test_init_creates_project() { + let temp = temp_dir("init_creates"); + let project_name = "test-quantum-app"; + let project_dir = temp.join(project_name); + + let output = zlup_bin() + .current_dir(&temp) + .args(["init", project_name]) + .output() + .expect("failed to run"); + + assert!( + output.status.success(), + "init failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + // Check files were created + assert!(project_dir.exists(), "project dir should exist"); + assert!( + project_dir.join("zlup.toml").exists(), + "zlup.toml should exist" + ); + assert!( + project_dir.join("main.zlp").exists(), + "main.zlp should exist" + ); + + // Check zlup.toml content + let toml_content = fs::read_to_string(project_dir.join("zlup.toml")).expect("read zlup.toml"); + assert!(toml_content.contains("name = \"test-quantum-app\"")); + assert!(toml_content.contains("version = \"0.1.0\"")); + + // Check main.zlp content + let main_content = fs::read_to_string(project_dir.join("main.zlp")).expect("read main.zlp"); + assert!(main_content.contains("fn main()")); + assert!(main_content.contains("qalloc")); + + // Cleanup + cleanup_temp_dir(&temp); +} + +#[test] +fn test_init_project_exists_error() { + let temp = temp_dir("init_exists"); + let project_name = "existing-project"; + let project_dir = temp.join(project_name); + + // Create the directory first + fs::create_dir_all(&project_dir).expect("create dir"); + + let output = zlup_bin() + .current_dir(&temp) + .args(["init", project_name]) + .output() + .expect("failed to run"); + + assert!(!output.status.success(), "should fail when project exists"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("exists"), + "should report project exists: {}", + stderr + ); + + // Cleanup + cleanup_temp_dir(&temp); +} + +// ============================================================================= +// Build Command +// ============================================================================= + +#[test] +fn test_build_help() { + let output = zlup_bin() + .args(["build", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Build")); + assert!(stdout.contains("strict")); + assert!(stdout.contains("target")); +} + +#[test] +fn test_build_no_config_error() { + let temp = temp_dir("build_no_config"); + + let output = zlup_bin() + .current_dir(&temp) + .args(["build"]) + .output() + .expect("failed to run"); + + assert!(!output.status.success(), "should fail without zlup.toml"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("config") || stderr.contains("not found"), + "should report config not found: {}", + stderr + ); + + // Cleanup + cleanup_temp_dir(&temp); +} + +#[test] +fn test_init_then_build() { + let temp = temp_dir("init_then_build"); + let project_name = "buildable-project"; + let project_dir = temp.join(project_name); + + // Initialize project + let output = zlup_bin() + .current_dir(&temp) + .args(["init", project_name]) + .output() + .expect("failed to run init"); + + assert!( + output.status.success(), + "init failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + // Build project + let output = zlup_bin() + .current_dir(&project_dir) + .args(["build"]) + .output() + .expect("failed to run build"); + + assert!( + output.status.success(), + "build failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + // Check output file was created + let output_file = project_dir.join("build").join("main.slr.json"); + assert!( + output_file.exists(), + "output file should exist at {:?}", + output_file + ); + + // Verify it's valid JSON + let content = fs::read_to_string(&output_file).expect("read output"); + let json: serde_json::Value = serde_json::from_str(&content).expect("valid JSON"); + assert_eq!(json["type"], "Program"); + + // Cleanup + cleanup_temp_dir(&temp); +} + +#[test] +fn test_build_with_strict_override() { + let temp = temp_dir("build_strict"); + let project_name = "strict-project"; + let project_dir = temp.join(project_name); + + // Initialize project + let output = zlup_bin() + .current_dir(&temp) + .args(["init", project_name]) + .output() + .expect("failed to run init"); + + assert!( + output.status.success(), + "init failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + // Build with strict override + let output = zlup_bin() + .current_dir(&project_dir) + .args(["build", "--strict", "true"]) + .output() + .expect("failed to run build"); + + // In strict mode, the build should show "strict" in the output + // Note: The actual success/failure depends on semantic analyzer's strict mode behavior + // which may flag qubit operations that aren't fully tracked + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("strict"), + "should show strict mode in output: {}", + stderr + ); + + // Cleanup + cleanup_temp_dir(&temp); +} + +// ============================================================================= +// Example Files Integration Tests +// ============================================================================= + +/// Get the examples directory path relative to the project root +fn examples_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples") +} + +/// Test that an example file parses and semantic checks correctly +fn test_example_checks(filename: &str) { + let example_path = examples_dir().join(filename); + assert!( + example_path.exists(), + "Example file should exist: {:?}", + example_path + ); + + let output = zlup_bin() + .args(["check", example_path.to_str().unwrap()]) + .output() + .expect("failed to run check"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "Example {} should pass semantic check.\nStderr: {}", + filename, + stderr + ); +} + +/// Test that an example file compiles to SLR-AST +fn test_example_compiles_slr(filename: &str) { + let example_path = examples_dir().join(filename); + assert!( + example_path.exists(), + "Example file should exist: {:?}", + example_path + ); + + let output = zlup_bin() + .args([ + "compile", + example_path.to_str().unwrap(), + "--format", + "slr", + "-o", + "-", + ]) + .output() + .expect("failed to run compile"); + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "Example {} should compile to SLR.\nStderr: {}\nStdout: {}", + filename, + stderr, + stdout + ); + + // Verify output is valid JSON + let json_result: Result = serde_json::from_str(&stdout); + assert!( + json_result.is_ok(), + "Example {} SLR output should be valid JSON: {:?}", + filename, + json_result.err() + ); +} + +/// Test that an example file compiles to QASM +fn test_example_compiles_qasm(filename: &str) { + let example_path = examples_dir().join(filename); + assert!( + example_path.exists(), + "Example file should exist: {:?}", + example_path + ); + + let output = zlup_bin() + .args([ + "compile", + example_path.to_str().unwrap(), + "--format", + "qasm", + "-o", + "-", + ]) + .output() + .expect("failed to run compile"); + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "Example {} should compile to QASM.\nStderr: {}\nStdout: {}", + filename, + stderr, + stdout + ); + + // Verify output contains QASM header + assert!( + stdout.contains("OPENQASM") || stdout.contains("qreg"), + "Example {} QASM output should contain QASM syntax", + filename + ); +} + +// Bell State Example +#[test] +fn test_example_bell_state_checks() { + test_example_checks("bell_state.zlp"); +} + +#[test] +fn test_example_bell_state_compiles_slr() { + test_example_compiles_slr("bell_state.zlp"); +} + +#[test] +fn test_example_bell_state_compiles_qasm() { + test_example_compiles_qasm("bell_state.zlp"); +} + +// GHZ State Example +#[test] +fn test_example_ghz_state_checks() { + test_example_checks("ghz_state.zlp"); +} + +#[test] +fn test_example_ghz_state_compiles_slr() { + test_example_compiles_slr("ghz_state.zlp"); +} + +#[test] +fn test_example_ghz_state_compiles_qasm() { + test_example_compiles_qasm("ghz_state.zlp"); +} + +// Grover 2-qubit Example +#[test] +fn test_example_grover_2qubit_checks() { + test_example_checks("grover_2qubit.zlp"); +} + +#[test] +fn test_example_grover_2qubit_compiles_slr() { + test_example_compiles_slr("grover_2qubit.zlp"); +} + +#[test] +fn test_example_grover_2qubit_compiles_qasm() { + test_example_compiles_qasm("grover_2qubit.zlp"); +} + +// QFT 3-qubit Example +#[test] +fn test_example_qft_3qubit_checks() { + test_example_checks("qft_3qubit.zlp"); +} + +#[test] +fn test_example_qft_3qubit_compiles_slr() { + test_example_compiles_slr("qft_3qubit.zlp"); +} + +#[test] +fn test_example_qft_3qubit_compiles_qasm() { + test_example_compiles_qasm("qft_3qubit.zlp"); +} + +// Simple QEC Example +#[test] +fn test_example_simple_qec_checks() { + test_example_checks("simple_qec.zlp"); +} + +#[test] +fn test_example_simple_qec_compiles_slr() { + test_example_compiles_slr("simple_qec.zlp"); +} + +#[test] +fn test_example_simple_qec_compiles_qasm() { + test_example_compiles_qasm("simple_qec.zlp"); +} + +// Teleportation Example +#[test] +fn test_example_teleportation_checks() { + test_example_checks("teleportation.zlp"); +} + +#[test] +fn test_example_teleportation_compiles_slr() { + test_example_compiles_slr("teleportation.zlp"); +} + +#[test] +fn test_example_teleportation_compiles_qasm() { + test_example_compiles_qasm("teleportation.zlp"); +} + +// ============================================================================= +// Analyze Command +// ============================================================================= + +#[test] +fn test_analyze_help() { + let output = zlup_bin() + .args(["analyze", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("parallelism")); + assert!(stdout.contains("format")); + assert!(stdout.contains("verbose")); +} + +#[test] +fn test_analyze_stdin_text() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!( + output.status.success(), + "analyze failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Parallelism Analysis"), "Missing header"); + assert!(stdout.contains("Allocators:"), "Missing allocators section"); + assert!(stdout.contains("q"), "Missing allocator q"); + assert!( + stdout.contains("Function Analysis:"), + "Missing function analysis" + ); + assert!(stdout.contains("main"), "Missing main function"); +} + +#[test] +fn test_analyze_stdin_json() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-", "--format", "json"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!( + output.status.success(), + "analyze failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse as JSON to verify structure + let json: serde_json::Value = + serde_json::from_str(&stdout).expect("Output should be valid JSON"); + + assert!( + json["allocators"].is_array(), + "Should have allocators array" + ); + assert!(json["functions"].is_array(), "Should have functions array"); + assert!( + json["parallel_layers"].is_array(), + "Should have parallel_layers array" + ); + assert!( + json["total_operations"].is_number(), + "Should have total_operations" + ); +} + +#[test] +fn test_analyze_verbose() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-", "--verbose"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!( + output.status.success(), + "analyze failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Dependency Graph"), + "Verbose should include dependency graph" + ); + assert!( + stdout.contains("Parallel Layers"), + "Verbose should include parallel layers" + ); +} + +#[test] +fn test_analyze_disjoint_allocators() { + // Two independent allocators should show parallelism + let source = r#"fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + h q1[0]; + h q2[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-", "--format", "json"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + + // Should have 2 allocators + let allocators = json["allocators"].as_array().expect("allocators array"); + assert_eq!(allocators.len(), 2, "Should have 2 allocators"); + + // Check max parallelism > 1 (H gates on different allocators can run in parallel) + let functions = json["functions"].as_array().expect("functions array"); + let main_func = &functions[0]; + let max_parallelism = main_func["max_parallelism"] + .as_u64() + .expect("max_parallelism"); + assert!( + max_parallelism >= 2, + "Disjoint allocators should enable parallelism" + ); +} + +#[test] +fn test_analyze_parse_error() { + // Invalid syntax should fail + let source = "fn main( { }"; // Missing closing paren and return type + + let mut child = zlup_bin() + .args(["analyze", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(!output.status.success(), "Should fail on parse error"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("parse") || stderr.contains("error") || stderr.contains("expected"), + "Should report parse error: {}", + stderr + ); +} + +#[test] +fn test_analyze_file_not_found() { + let output = zlup_bin() + .args(["analyze", "nonexistent_file_12345.zlp"]) + .output() + .expect("failed to run"); + + assert!(!output.status.success(), "Should fail on missing file"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("read") || stderr.contains("No such file") || stderr.contains("not found"), + "Should report file not found: {}", + stderr + ); +} + +#[test] +fn test_analyze_semantic_error() { + // Reference undefined variable + let source = r#"fn main() -> unit { + h undefined_var[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + // Note: semantic errors in permissive mode may still allow analysis + // This test verifies the command handles the input without crashing + let _ = output.status; // May or may not succeed depending on strictness +} diff --git a/exp/zlup/tests/proptest.rs b/exp/zlup/tests/proptest.rs new file mode 100644 index 000000000..ecfe19c99 --- /dev/null +++ b/exp/zlup/tests/proptest.rs @@ -0,0 +1,3528 @@ +//! Property-based tests for Zlup using proptest. +//! +//! These tests verify invariants that should hold for all inputs: +//! - Parser never panics on any UTF-8 input +//! - Semantic analyzer never panics on any valid AST +//! - Type system invariants are maintained +//! - BitWidth constraints are enforced + +use proptest::prelude::*; +use zlup::semantic::{BitWidth, SemanticAnalyzer, Type}; + +// ============================================================================= +// Parser Properties +// ============================================================================= + +proptest! { + /// The parser should never panic on any UTF-8 string input. + #[test] + fn parser_never_panics(input in ".*") { + // Just call parse - we don't care about the result, only that it doesn't panic + let _ = zlup::parse(&input); + } + + /// The parser should handle strings up to 10KB without issues. + #[test] + fn parser_handles_large_input(input in ".{0,10000}") { + let _ = zlup::parse(&input); + } + + /// Parsing valid function declarations should work. + #[test] + fn parser_valid_function( + name in "[a-z][a-z0-9_]{0,20}", + ret_type in prop_oneof!["unit", "u32", "bool", "f64"] + ) { + let source = format!("fn {}() -> {} {{ return {}; }}", + name, + ret_type, + match ret_type.as_str() { + "unit" => "unit", + "u32" => "0", + "bool" => "true", + "f64" => "0.0", + _ => "unit", + } + ); + let result = zlup::parse(&source); + prop_assert!(result.is_ok(), "Failed to parse: {}", source); + } +} + +// ============================================================================= +// BitWidth Properties +// ============================================================================= + +proptest! { + /// BitWidth::new should accept values 1-128 and reject others. + #[test] + fn bitwidth_valid_range(bits in 1u16..=128) { + let bw = BitWidth::new(bits); + prop_assert!(bw.is_some(), "BitWidth::new({}) should succeed", bits); + prop_assert_eq!(bw.unwrap().get(), bits); + } + + /// BitWidth::new should reject 0. + #[test] + fn bitwidth_rejects_zero(_dummy in 0..1u8) { + let bw = BitWidth::new(0); + prop_assert!(bw.is_none(), "BitWidth::new(0) should fail"); + } + + /// BitWidth::new should reject values > 128. + #[test] + fn bitwidth_rejects_large(bits in 129u16..=u16::MAX) { + let bw = BitWidth::new(bits); + prop_assert!(bw.is_none(), "BitWidth::new({}) should fail", bits); + } +} + +// ============================================================================= +// Type System Properties +// ============================================================================= + +proptest! { + /// A type should be resolved if and only if it contains no Unknown. + #[test] + fn type_resolved_iff_no_unknown(_bits in 1u16..=128) { + // Concrete types should be resolved + let concrete = Type::Bool; + prop_assert!(concrete.is_resolved()); + prop_assert!(!concrete.contains_unknown()); + + // Unknown should not be resolved + let unknown = Type::Unknown; + prop_assert!(!unknown.is_resolved()); + prop_assert!(unknown.contains_unknown()); + } + + /// Nested types with Unknown should not be resolved. + #[test] + fn nested_unknown_not_resolved(_dummy in 0..1u8) { + let nested = Type::Optional { + inner: Box::new(Type::Unknown), + }; + prop_assert!(!nested.is_resolved()); + prop_assert!(nested.contains_unknown()); + } + + /// Array types inherit Unknown status from their element type. + #[test] + fn array_unknown_from_element(size in 0u64..1000) { + // Array with concrete element + let concrete_array = Type::Array { + element: Box::new(Type::Bool), + size: Some(size), + }; + prop_assert!(concrete_array.is_resolved()); + + // Array with Unknown element + let unknown_array = Type::Array { + element: Box::new(Type::Unknown), + size: Some(size), + }; + prop_assert!(!unknown_array.is_resolved()); + } +} + +// ============================================================================= +// Semantic Analyzer Properties +// ============================================================================= + +proptest! { + /// Semantic analysis of valid programs should not panic. + #[test] + fn semantic_valid_program_no_panic( + var_name in "[a-z][a-z0-9_]{0,10}", + value in 0i64..1000 + ) { + let source = format!( + "fn main() -> unit {{ {} := {}; return unit; }}", + var_name, value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + // Should not panic, result doesn't matter + let _ = analyzer.analyze(&program); + } + } + + /// Error recovery should collect all errors without panicking. + #[test] + fn error_recovery_no_panic(input in "[a-zA-Z0-9_ ]{0,100}") { + // Wrap in function to make it potentially parseable + let source = format!("fn main() -> unit {{ {} return unit; }}", input); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + // Should not panic, may have errors + let _ = analyzer.analyze_collecting_errors(&program); + } + } +} + +// ============================================================================= +// Integer Literal Properties +// ============================================================================= + +proptest! { + /// Integer literals should parse correctly. + #[test] + fn integer_literals_parse(value in 0i64..i64::MAX / 2) { + let source = format!("x := {};", value); + let result = zlup::parse(&source); + prop_assert!(result.is_ok(), "Failed to parse integer literal: {}", value); + } + + /// Negative integer literals should parse correctly. + #[test] + fn negative_integers_parse(value in i64::MIN / 2..0i64) { + let source = format!("x := {};", value); + let result = zlup::parse(&source); + prop_assert!(result.is_ok(), "Failed to parse negative literal: {}", value); + } + + /// Float literals should parse correctly. + #[test] + fn float_literals_parse(value in -1e10f64..1e10f64) { + if value.is_finite() { + let source = format!("x := {:.6};", value); + let result = zlup::parse(&source); + // Some edge cases may not parse, that's OK + let _ = result; + } + } +} + +// ============================================================================= +// Identifier Properties +// ============================================================================= + +proptest! { + /// Valid identifiers should be accepted. + #[test] + fn valid_identifiers_accepted( + first in "[a-zA-Z_]", + rest in "[a-zA-Z0-9_]{0,30}" + ) { + let ident = format!("{}{}", first, rest); + let source = format!("{} := 42;", ident); + let result = zlup::parse(&source); + prop_assert!(result.is_ok(), "Failed to parse identifier: {}", ident); + } +} + +// ============================================================================= +// NEGATIVE TESTS - These verify that invalid inputs ARE rejected +// ============================================================================= + +proptest! { + /// Type mismatches should be rejected by semantic analysis. + #[test] + fn type_mismatch_rejected(value in 0i64..1000) { + // Assigning integer to bool variable should fail + let source = format!( + "fn main() -> unit {{ x: bool = {}; return unit; }}", + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Type mismatch should be rejected: {}", source); + } + } + + /// Undefined variables should be rejected. + #[test] + fn undefined_variable_rejected( + var_name in "[a-z][a-z0-9]{2,10}" // At least 2 chars to avoid built-ins like 'e', 'pi' + ) { + // Skip known built-in constants + prop_assume!(!["pi", "tau", "e", "qalloc", "measure", "mz", "mx", "my"].contains(&var_name.as_str())); + + // Using undefined variable should fail + let source = format!( + "fn main() -> unit {{ x := {} + 1; return unit; }}", + var_name + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Undefined variable should be rejected: {}", source); + } + } + + /// Undefined types should be rejected. + #[test] + fn undefined_type_rejected( + type_name in "[A-Z][a-zA-Z0-9]{0,15}" + ) { + // Using undefined type should fail + let source = format!( + "fn main() -> unit {{ x: {} = 42; return unit; }}", + type_name + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Undefined type should be rejected: {}", source); + } + } + + /// Return type mismatches should be rejected. + #[test] + fn return_type_mismatch_rejected(value in 0i64..1000) { + // Returning integer from bool function should fail + let source = format!( + "fn foo() -> bool {{ return {}; }}", + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Return type mismatch should be rejected: {}", source); + } + } + + /// Duplicate function names should be rejected. + #[test] + fn duplicate_function_rejected( + name in "[a-z][a-z0-9]{0,10}" + ) { + let source = format!( + "fn {}() -> unit {{ return unit; }} fn {}() -> unit {{ return unit; }}", + name, name + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Duplicate function should be rejected: {}", source); + } + } + + /// Invalid identifier starting with digit should fail to parse. + #[test] + fn identifier_starting_with_digit_rejected( + digit in 0u8..10, + rest in "[a-zA-Z0-9_]{0,10}" + ) { + let ident = format!("{}{}", digit, rest); + let source = format!("{} := 42;", ident); + // This should fail to parse (identifiers can't start with digits) + let result = zlup::parse(&source); + prop_assert!(result.is_err(), "Identifier starting with digit should be rejected: {}", ident); + } +} + +// ============================================================================= +// Mutation Testing - Mutate valid programs to create invalid ones +// ============================================================================= + +proptest! { + /// Removing return statement from non-unit function should fail in strict mode. + #[test] + fn missing_return_rejected_strict(value in 0i64..1000) { + let source = format!( + "fn foo() -> u32 {{ x := {}; }}", // Missing return + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Missing return should be rejected in strict mode: {}", source); + } + } + + /// Wrong argument count should be rejected. + #[test] + fn wrong_arg_count_rejected( + extra_args in 1usize..5 + ) { + // Calling function with wrong number of arguments + let args = (0..extra_args).map(|i| format!("{}", i)).collect::>().join(", "); + let source = format!( + "fn foo() -> unit {{ return unit; }} fn main() -> unit {{ foo({}); return unit; }}", + args + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Wrong argument count should be rejected: {}", source); + } + } +} + +// ============================================================================= +// Quantum-Specific Negative Tests +// ============================================================================= + +proptest! { + /// Gate on unprepared qubit should fail in strict mode. + #[test] + fn gate_on_unprepared_qubit_rejected_strict(index in 0usize..4) { + let source = format!( + "fn main() -> unit {{ q := qalloc(4); h q[{}]; return unit; }}", // Missing pz + index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Gate on unprepared qubit should be rejected in strict mode: {}", source); + } + } + + /// Qubit index out of bounds should be rejected. + #[test] + fn qubit_out_of_bounds_rejected( + capacity in 1usize..10, + index in 10usize..20 + ) { + let source = format!( + "fn main() -> unit {{ q := qalloc({}); pz q; h q[{}]; return unit; }}", + capacity, index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // Should fail because index >= capacity + prop_assert!(result.is_err(), "Qubit out of bounds should be rejected: {}", source); + } + } +} + +// ============================================================================= +// Mutability Error Tests +// ============================================================================= + +proptest! { + /// Assignment to immutable variable should be rejected. + #[test] + fn immutable_assignment_rejected(value in 0i64..1000) { + // x is immutable (no mut), so x = value should fail + let source = format!( + "fn main() -> unit {{ x := 1; x = {}; return unit; }}", + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Assignment to immutable variable should be rejected: {}", source); + } + } + + /// Reassignment to mutable variable should succeed. + #[test] + fn mutable_assignment_allowed(value in 0i64..1000) { + // mut x is mutable, so x = value should succeed + let source = format!( + "fn main() -> unit {{ mut x := 1; x = {}; return unit; }}", + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_ok(), "Assignment to mutable variable should succeed: {:?}", result); + } + } + + /// Mutation via method on immutable allocator should be rejected. + #[test] + fn immutable_allocator_child_rejected(capacity in 1usize..10) { + // base is immutable, so base.child() should fail (child requires mut) + let source = format!( + "fn main() -> unit {{ base := qalloc(8); q := base.child({}); return unit; }}", + capacity + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Child of immutable allocator should be rejected: {}", source); + } + } +} + +// ============================================================================= +// Gate After Measurement Tests (Quantum Safety) +// ============================================================================= + +proptest! { + /// Gate after measurement without re-preparing should be rejected in strict mode. + #[test] + fn gate_after_measurement_rejected_strict(index in 0usize..4) { + // After measurement, qubit returns to unprepared state + // Applying a gate without pz should fail + let source = format!( + r#"fn main() -> unit {{ + mut q := qalloc(4); + pz q; + result := mz q[{}]; + h q[{}]; // Should fail - qubit is unprepared after measurement + return unit; + }}"#, + index, index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Gate after measurement should be rejected in strict mode: {}", source); + } + } + + /// Gate after measurement with re-prepare should succeed. + #[test] + fn gate_after_measurement_with_prepare_allowed(index in 0usize..4) { + // After measurement, re-prepare with pz, then gate should work + let source = format!( + r#"fn main() -> unit {{ + mut q := qalloc(4); + pz q; + result := mz q[{}]; + pz q[{}]; // Re-prepare the qubit + h q[{}]; // Now this should succeed + return unit; + }}"#, + index, index, index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_ok(), "Gate after re-prepare should succeed: {:?}", result); + } + } +} + +// ============================================================================= +// Invalid Gate Arity Tests +// ============================================================================= + +proptest! { + /// Single-qubit gate with tuple target should be rejected. + #[test] + fn single_qubit_gate_with_two_targets_rejected(_dummy in 0..1u8) { + // H is a single-qubit gate, giving it two qubits should fail + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + h (q[0], q[1]); // H takes 1 qubit, not 2 + return unit; + } + "#; + + if let Ok(program) = zlup::parse(source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Single-qubit gate with two targets should be rejected"); + } + } + + /// Two-qubit gate with single target should be rejected. + #[test] + fn two_qubit_gate_with_one_target_rejected(index in 0usize..4) { + // CX is a two-qubit gate, giving it one qubit should fail + let source = format!( + r#"fn main() -> unit {{ + mut q := qalloc(4); + pz q; + cx q[{}]; // CX takes 2 qubits, not 1 + return unit; + }}"#, + index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Two-qubit gate with one target should be rejected: {}", source); + } + } + + /// Two-qubit gate with correct arity should succeed. + #[test] + fn two_qubit_gate_correct_arity_allowed(_dummy in 0..1u8) { + // CX with two qubits should succeed + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + cx (q[0], q[1]); // CX takes 2 qubits - correct + return unit; + } + "#; + + if let Ok(program) = zlup::parse(source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_ok(), "Two-qubit gate with correct arity should succeed: {:?}", result); + } + } + + /// Three-qubit gate (CCX/Toffoli) with wrong arity should be rejected. + #[test] + fn three_qubit_gate_with_two_targets_rejected(_dummy in 0..1u8) { + // CCX is a three-qubit gate + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + ccx (q[0], q[1]); // CCX takes 3 qubits, not 2 + return unit; + } + "#; + + if let Ok(program) = zlup::parse(source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Three-qubit gate with two targets should be rejected"); + } + } +} + +// ============================================================================= +// Recursive Call Tests (NASA Power of 10 Compliance) +// ============================================================================= + +proptest! { + /// Direct recursion is always rejected (NASA Power of 10 compliance). + /// Use FFI with Rust if recursive algorithms are needed. + #[test] + fn direct_recursion_rejected_strict(n in 1i64..10) { + // Function calling itself directly + let source = format!( + r#"fn factorial(n: i64) -> i64 {{ + if n <= 1 {{ + return 1; + }} + return n * factorial(n - {}); + }} + fn main() -> unit {{ x := factorial(5); return unit; }}"#, + n.min(1) // Always subtract at least 1 + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Direct recursion should be rejected: {}", source); + } + } + + /// Mutual recursion is always rejected (NASA Power of 10 compliance). + #[test] + fn mutual_recursion_rejected_strict(_dummy in 0..1u8) { + // Two functions calling each other + let source = r#" + fn is_even(n: i64) -> bool { + if n == 0 { return true; } + return is_odd(n - 1); + } + fn is_odd(n: i64) -> bool { + if n == 0 { return false; } + return is_even(n - 1); + } + fn main() -> unit { x := is_even(10); return unit; } + "#; + + if let Ok(program) = zlup::parse(source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Mutual recursion should be rejected"); + } + } + + /// Non-recursive functions should succeed. + #[test] + fn non_recursive_allowed_strict(a in 0i64..100, b in 0i64..100) { + // Regular function calls (no recursion) should be fine + let source = format!( + r#"fn add(x: i64, y: i64) -> i64 {{ return x + y; }} + fn main() -> unit {{ result := add({}, {}); return unit; }}"#, + a, b + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_ok(), "Non-recursive function should succeed: {:?}", result); + } + } +} + +/// Recursion is rejected even in permissive mode (no escape hatch). +#[test] +fn recursion_rejected_even_permissive() { + let source = r#" + fn factorial(n: i64) -> i64 { + if n <= 1 { return 1; } + return n * factorial(n - 1); + } + fn main() -> unit { x := factorial(5); return unit; } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Recursion should be rejected even in permissive mode" + ); +} + +// ============================================================================= +// ============================================================================= +// Performance Regression Tests +// ============================================================================= + +/// Test that deeply nested parens don't cause exponential parsing time. +/// This input was found by fuzzing to be slow. +#[test] +fn slow_input_deeply_nested_parens() { + use std::time::{Duration, Instant}; + + // This input caused 445s parse time in fuzzing + let input = "a:=((Z/[[((((((((((((((((((((((/\n"; + + let start = Instant::now(); + let _ = zlup::parse(input); + let elapsed = start.elapsed(); + + println!("Parse time for deeply nested input: {:?}", elapsed); + + // Should complete in under 1 second, not 445 seconds + assert!( + elapsed < Duration::from_secs(1), + "Parsing took too long: {:?} (expected < 1s)", + elapsed + ); +} + +/// Test another slow input found by fuzzing - deeply nested parens in function call. +#[test] +fn slow_input_nested_call_parens() { + use std::time::{Duration, Instant}; + + // This input caused timeout in fuzzing: k:k=z(((((((((((((((((((((((((/inl... + let input = "k:k=z(((((((((((((((((((((((((/inl\x1a\x00at\r\r:0"; + + let start = Instant::now(); + let _ = zlup::parse(input); + let elapsed = start.elapsed(); + + println!("Parse time for nested call parens: {:?}", elapsed); + + // Should complete in under 1 second + assert!( + elapsed < Duration::from_secs(1), + "Parsing took too long: {:?} (expected < 1s)", + elapsed + ); +} + +// ============================================================================= +// Additional Parser Negative Tests +// ============================================================================= + +/// Unclosed parentheses should fail to parse. +#[test] +fn unclosed_paren_rejected() { + let inputs = [ + "x := (1 + 2;", + "x := ((1);", + "fn foo( {}", + "x := func(a, b;", + ]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Unclosed paren should be rejected: {}", + input + ); + } +} + +/// Unclosed brackets should fail to parse. +#[test] +fn unclosed_bracket_rejected() { + let inputs = ["x := [1, 2, 3;", "x := a[0;", "x: [4]u8 = [1, 2;"]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Unclosed bracket should be rejected: {}", + input + ); + } +} + +/// Unclosed braces should fail to parse. +#[test] +fn unclosed_brace_rejected() { + let inputs = [ + "fn foo() {", + "x := Point { x: 1, y: 2;", + "if true { x := 1;", + ]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Unclosed brace should be rejected: {}", + input + ); + } +} + +/// Reserved keywords as identifiers should fail. +#[test] +fn keyword_as_identifier_rejected() { + let keywords = [ + "fn", "if", "else", "for", "return", "struct", "enum", "true", "false", + ]; + for kw in keywords { + let source = format!("{} := 42;", kw); + let result = zlup::parse(&source); + assert!( + result.is_err(), + "Keyword '{}' as identifier should be rejected", + kw + ); + } +} + +/// Invalid number literals should fail to parse. +#[test] +fn invalid_number_literal_rejected() { + let inputs = [ + "x := 0x;", // hex with no digits + "x := 0b;", // binary with no digits + "x := 0o;", // octal with no digits + "x := 1.;", // trailing dot with no fraction + ]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Invalid number literal should be rejected: {}", + input + ); + } +} + +/// Deprecated measurement syntax should be rejected. +#[test] +fn deprecated_measurement_syntax_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + r := mz(u1, q[0]); + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Deprecated mz(type, target) syntax should be rejected" + ); +} + +/// Empty function body is valid but missing semicolon is not. +#[test] +fn missing_semicolon_rejected() { + let inputs = [ + "x := 42", // missing semicolon on binding + "fn foo() -> u32 { return 42 }", // missing semicolon on return + ]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Missing semicolon should be rejected: {}", + input + ); + } +} + +// ============================================================================= +// Additional Semantic Negative Tests +// ============================================================================= + +/// Batch gate with wrong arity elements should be rejected. +#[test] +fn batch_gate_wrong_element_arity_rejected() { + // H is single-qubit, but we're giving it tuples + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + pz q; + h {(q[0], q[1]), (q[2], q[3])}; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "H gate with tuple elements should be rejected" + ); +} + +/// CX batch with single qubits instead of pairs should be rejected. +#[test] +fn batch_cx_wrong_element_type_rejected() { + // CX needs pairs, but we're giving single qubits + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + pz q; + cx {q[0], q[1], q[2], q[3]}; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "CX gate with single qubit elements should be rejected" + ); +} + +/// Using a non-qubit type where qubit is expected should be rejected. +#[test] +fn non_qubit_as_gate_target_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + h x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Integer as gate target should be rejected"); +} + +/// Break outside of loop should be rejected. +#[test] +fn break_outside_loop_rejected() { + let source = r#" + pub fn main() -> unit { + break; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Break outside loop should be rejected"); +} + +/// Continue outside of loop should be rejected. +#[test] +fn continue_outside_loop_rejected() { + let source = r#" + pub fn main() -> unit { + continue; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Continue outside loop should be rejected"); +} + +/// Nested tick blocks should be rejected. +#[test] +fn nested_tick_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + tick { + tick { + h q[0]; + } + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Nested tick blocks should be rejected"); +} + +/// Same qubit used twice in same tick should be rejected. +#[test] +fn duplicate_qubit_in_tick_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + tick { + h q[0]; + x q[0]; + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Same qubit used twice in tick should be rejected" + ); +} + +/// Catch on non-error type should be rejected. +#[test] +fn catch_on_non_error_type_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := x catch 0; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Catch on non-error type should be rejected" + ); +} + +/// Array index with non-integer should be rejected. +#[test] +fn array_index_non_integer_rejected() { + let source = r#" + pub fn main() -> unit { + arr: [4]u32 = [1, 2, 3, 4]; + x := arr[true]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Array index with boolean should be rejected" + ); +} + +/// Field access on non-struct should be rejected. +#[test] +fn field_access_on_non_struct_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := x.field; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Field access on integer should be rejected" + ); +} + +/// Accessing undefined struct field should be rejected. +#[test] +fn undefined_struct_field_rejected() { + let source = r#" + Point := struct { + x: i32, + y: i32, + }; + pub fn main() -> unit { + p := Point { x: 1, y: 2 }; + z := p.z; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Accessing undefined field should be rejected" + ); +} + +/// Duplicate struct field in initialization should be rejected. +#[test] +fn duplicate_struct_field_init_rejected() { + let source = r#" + Point := struct { + x: i32, + y: i32, + }; + pub fn main() -> unit { + p := Point { x: 1, x: 2, y: 3 }; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Duplicate field in struct init should be rejected" + ); +} + +/// Binary operation with incompatible types should be rejected. +#[test] +fn binary_op_type_mismatch_rejected() { + let inputs = [ + ("x := true + 1;", "bool + int"), + ("x := \"hello\" - 5;", "string - int"), + ("x := 1.5 and true;", "float and bool"), + ]; + for (source, desc) in inputs { + let wrapped = format!("pub fn main() -> unit {{ {} }}", source); + let program = zlup::parse(&wrapped).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Binary op {} should be rejected", desc); + } +} + +/// Calling a non-function should be rejected. +#[test] +fn call_non_function_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := x(1, 2); + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Calling an integer should be rejected"); +} + +/// Return with value from unit function should be rejected. +#[test] +fn return_value_from_unit_function_rejected() { + let source = r#" + pub fn main() -> unit { + return 42; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Returning value from unit function should be rejected" + ); +} + +/// Wrong type in struct field initialization should be rejected. +#[test] +fn struct_field_wrong_type_rejected() { + let source = r#" + Point := struct { + x: i32, + y: i32, + }; + pub fn main() -> unit { + p := Point { x: "hello", y: 2 }; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "String in i32 field should be rejected"); +} + +/// Qubit already prepared should be rejected (double prepare). +#[test] +fn qubit_already_prepared_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + pz q; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.set_strict_mode(true); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Double prepare should be rejected in strict mode" + ); +} + +/// Duplicate qubit in measurement should be rejected. +#[test] +fn duplicate_qubit_in_measurement_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + r := mz([2]u1) [q[0], q[0]]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Duplicate qubit in measurement should be rejected" + ); +} + +/// Measurement size mismatch should be rejected. +#[test] +fn measurement_size_mismatch_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + pz q; + r := mz([2]u1) [q[0], q[1], q[2]]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Measurement size mismatch should be rejected" + ); +} + +/// Using orelse on non-optional should be rejected. +#[test] +fn orelse_on_non_optional_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := x orelse 0; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "orelse on non-optional should be rejected"); +} + +/// Empty array without type annotation should be rejected. +#[test] +fn empty_array_no_type_rejected() { + let source = r#" + pub fn main() -> unit { + arr := []; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Empty array without type should be rejected" + ); +} + +/// For loop with non-iterable should be rejected. +#[test] +fn for_loop_non_iterable_rejected() { + let source = r#" + pub fn main() -> unit { + for i in 42 { + x := i; + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "For loop over integer should be rejected"); +} + +/// Comparison between incompatible types should be rejected. +#[test] +fn comparison_type_mismatch_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42 == "hello"; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Comparing int to string should be rejected" + ); +} + +/// If condition must be boolean. +#[test] +fn if_non_boolean_condition_rejected() { + let source = r#" + pub fn main() -> unit { + if 42 { + x := 1; + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "If with integer condition should be rejected" + ); +} + +// ============================================================================= +// More Parser Negative Tests +// ============================================================================= + +/// Invalid escape sequences in strings should fail. +#[test] +fn invalid_string_escape_rejected() { + let inputs = [ + r#"x := "\q";"#, // \q is not a valid escape + r#"x := "\u1234";"#, // \u is not supported (use \x) + ]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Invalid escape should be rejected: {}", + input + ); + } +} + +/// Multiple expressions without separator should fail. +#[test] +fn missing_separator_rejected() { + let inputs = [ + "x := 1 y := 2;", // missing semicolon between statements + "fn foo() {} bar", // junk after function + ]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Missing separator should be rejected: {}", + input + ); + } +} + +/// Invalid type syntax should fail. +#[test] +fn invalid_type_syntax_rejected() { + let inputs = [ + "x: [;", // incomplete array type + "x: *;", // pointer to nothing + "x: ?;", // optional of nothing + ]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Invalid type should be rejected: {}", + input + ); + } +} + +/// Empty function parameter list with trailing comma should parse but empty param should fail. +#[test] +fn invalid_param_syntax_rejected() { + let inputs = [ + "fn foo(,) {}", // just a comma + "fn foo(x:) {}", // missing type + "fn foo(: i32) {}", // missing name + ]; + for input in inputs { + let result = zlup::parse(input); + assert!( + result.is_err(), + "Invalid param should be rejected: {}", + input + ); + } +} + +// ============================================================================= +// More Semantic Negative Tests +// ============================================================================= + +/// Negative array size should be rejected. +#[test] +fn negative_array_size_rejected() { + let source = r#" + pub fn main() -> unit { + arr: [-1]u32 = undefined; + } + "#; + // This might fail at parse or semantic level + let result = zlup::parse(source); + if let Ok(program) = result { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Negative array size should be rejected"); + } + // If it fails to parse, that's also acceptable +} + +/// Zero-size array should be valid but accessing it should fail. +#[test] +fn zero_array_access_rejected() { + let source = r#" + pub fn main() -> unit { + arr: [0]u32 = []; + x := arr[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Accessing element of zero-size array should be rejected" + ); +} + +/// Using undefined label with break should be rejected. +#[test] +fn undefined_break_label_rejected() { + let source = r#" + pub fn main() -> unit { + for i in 0..10 { + break :nonexistent; + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Break with undefined label should be rejected" + ); +} + +/// Assigning to a constant/comptime value should be rejected. +#[test] +fn assign_to_comptime_rejected() { + let source = r#" + SIZE := comptime 10; + pub fn main() -> unit { + SIZE = 20; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Assigning to comptime value should be rejected" + ); +} + +/// Division by zero at comptime should be rejected. +#[test] +fn comptime_division_by_zero_rejected() { + let source = r#" + X := comptime 10 / 0; + pub fn main() -> unit { + y := X; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Comptime division by zero should be rejected" + ); +} + +/// Invalid unary operator application should be rejected. +#[test] +fn invalid_unary_op_rejected() { + let inputs = [ + ("x := -true;", "negating bool"), + ("x := !42;", "logical not on int"), + ("x := ~true;", "bitwise not on bool"), + ]; + for (source, desc) in inputs { + let wrapped = format!("pub fn main() -> unit {{ {} }}", source); + let program = zlup::parse(&wrapped).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Invalid unary op {} should be rejected", + desc + ); + } +} + +/// Dereferencing a non-pointer should be rejected. +#[test] +fn deref_non_pointer_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := *x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Dereferencing non-pointer should be rejected" + ); +} + +/// Taking address of a literal should be rejected. +#[test] +fn address_of_literal_rejected() { + let source = r#" + pub fn main() -> unit { + x := &42; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // This might be allowed in some contexts, but typically rejected + // If it's allowed, we can remove this test + assert!(result.is_err(), "Address of literal should be rejected"); +} + +/// Mixing qubits from different allocators in same gate should be rejected. +#[test] +fn mixed_allocator_gate_rejected() { + let source = r#" + pub fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + pz q1; + pz q2; + cx (q1[0], q2[0]); + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // This might actually be allowed - remove if so + // Cross-allocator gates might be valid in some quantum systems + if result.is_err() { + // Good - it's rejected as expected + } + // Don't assert - this might be architecture-dependent +} + +/// Parameterized gate with wrong parameter type should be rejected. +#[test] +fn gate_wrong_param_type_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + pz q; + rx("not an angle") q[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Gate with string parameter should be rejected" + ); +} + +/// Enum variant that doesn't exist should be rejected. +#[test] +fn undefined_enum_variant_rejected() { + let source = r#" + Color := enum { Red, Green, Blue }; + pub fn main() -> unit { + c := Color.Yellow; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Undefined enum variant should be rejected"); +} + +/// Using a type as a value incorrectly should be rejected. +#[test] +fn type_as_value_rejected() { + let source = r#" + pub fn main() -> unit { + x := u32 + 1; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Using type in arithmetic should be rejected" + ); +} + +/// Bitwise operations on floats should be rejected. +#[test] +fn bitwise_on_float_rejected() { + let inputs = [ + ("x := 1.0 & 2.0;", "bitwise and on floats"), + ("x := 1.0 | 2.0;", "bitwise or on floats"), + ("x := 1.0 ^ 2.0;", "bitwise xor on floats"), + ("x := 1.0 << 2;", "left shift on float"), + ]; + for (source, desc) in inputs { + let wrapped = format!("pub fn main() -> unit {{ {} }}", source); + let program = zlup::parse(&wrapped).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "{} should be rejected", desc); + } +} + +/// Switch with duplicate integer cases should be rejected. +#[test] +fn switch_duplicate_case_rejected() { + let source = r#" + pub fn main() -> unit { + x := 1; + switch (x) { + 1 => unit, + 1 => unit, + else => unit, + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Duplicate switch case should be rejected"); +} + +/// Switch with duplicate boolean cases should be rejected. +#[test] +fn switch_duplicate_bool_case_rejected() { + let source = r#" + pub fn main() -> unit { + x := true; + switch (x) { + true => unit, + true => unit, + else => unit, + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Duplicate bool switch case should be rejected" + ); +} + +/// Switch with duplicate string cases should be rejected. +#[test] +fn switch_duplicate_string_case_rejected() { + let source = r#" + pub fn main() -> unit { + x := "hello"; + switch (x) { + "hello" => unit, + "world" => unit, + "hello" => unit, + else => unit, + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Duplicate string switch case should be rejected" + ); +} + +/// Switch with unique cases should be allowed. +#[test] +fn switch_unique_cases_allowed() { + let source = r#" + pub fn main() -> unit { + x := 1; + switch (x) { + 1 => unit, + 2 => unit, + 3 => unit, + else => unit, + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Switch with unique cases should be allowed: {:?}", + result + ); +} + +/// Missing struct fields in initialization should be rejected. +#[test] +fn missing_struct_field_rejected() { + let source = r#" + Point := struct { + x: i32, + y: i32, + }; + pub fn main() -> unit { + p := Point { x: 1 }; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Missing struct field should be rejected"); +} + +// ============================================================================= +// Logging Tests +// ============================================================================= + +/// Standard log levels should parse correctly. +#[test] +fn log_standard_levels_parse() { + let source = r#" + pub fn main() -> unit { + @emit.log.trace(f"trace message"); + @emit.log.debug(f"debug message"); + @emit.log.info(f"info message"); + @emit.log.warn(f"warn message"); + @emit.log.error(f"error message"); + return unit; + } + "#; + let result = zlup::parse(source); + assert!( + result.is_ok(), + "Standard log levels should parse: {:?}", + result.err() + ); +} + +/// Log with sub-namespace should parse correctly. +#[test] +fn log_with_namespace_parses() { + let source = r#" + pub fn main() -> unit { + @emit.log.debug("subns", f"message with namespace"); + @emit.log.info("my::nested::ns", f"nested namespace"); + return unit; + } + "#; + let result = zlup::parse(source); + assert!( + result.is_ok(), + "Log with namespace should parse: {:?}", + result.err() + ); +} + +/// Bare log.info without @emit prefix is parsed as method call (not channel). +/// Since `log` is not defined, semantic analysis should fail. +#[test] +fn bare_log_not_channel() { + let source = r#" + pub fn main() -> unit { + log.info(f"missing @emit prefix"); + return unit; + } + "#; + // Parses as method call (log.info), but `log` is undefined + let program = zlup::parse(source).expect("Parses as method call"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // Semantic analysis should fail because `log` is not defined + assert!( + result.is_err(), + "Undefined variable 'log' should cause semantic error" + ); +} + +/// Log with data parameter should parse correctly. +#[test] +fn log_with_data_parses() { + let source = r#" + pub fn main() -> unit { + x := 42; + @emit.log.debug(f"value", data: x); + @emit.log.info("ns", f"with both", data: x); + return unit; + } + "#; + let result = zlup::parse(source); + assert!( + result.is_ok(), + "Log with data should parse: {:?}", + result.err() + ); +} + +/// Custom log level with @emit.log.at should parse correctly. +#[test] +fn log_custom_level_parses() { + let source = r#" + pub fn main() -> unit { + @emit.log.at(15, f"custom numeric level"); + @emit.log.at(25, "perf", f"with namespace"); + return unit; + } + "#; + let result = zlup::parse(source); + assert!( + result.is_ok(), + "Custom log level should parse: {:?}", + result.err() + ); +} + +/// Log expressions should pass semantic analysis. +#[test] +fn log_passes_semantic_analysis() { + let source = r#" + pub fn main() -> unit { + x := 42; + @emit.log.trace(f"trace"); + @emit.log.debug(f"x = {x}"); + @emit.log.info("ns", f"namespaced"); + @emit.log.warn(f"warning", data: x); + @emit.log.error(f"error"); + @emit.log.at(15, f"custom"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Log expressions should pass semantic analysis: {:?}", + result.err() + ); +} + +/// Log with f-string interpolation should work. +#[test] +fn log_fstring_interpolation_works() { + let source = r#" + pub fn main() -> unit { + name := "world"; + count := 42; + @emit.log.info(f"Hello {name}, count = {count}"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Log with f-string interpolation should work: {:?}", + result.err() + ); +} + +/// Log SLR codegen should emit LogStmt nodes. +#[test] +fn log_slr_codegen_emits_log_stmt() { + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.log.debug(f"test message"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr_program).expect("Should serialize"); + + assert!( + json.contains("LogStmt"), + "SLR output should contain LogStmt" + ); + assert!( + json.contains("debug"), + "SLR output should contain log level" + ); + assert!( + json.contains("test message"), + "SLR output should contain message" + ); +} + +/// Log SLR codegen should handle namespaces. +#[test] +fn log_slr_codegen_handles_namespace() { + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.log.info("myns", f"namespaced"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_module("testmod"); + let slr_program = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr_program).expect("Should serialize"); + + assert!( + json.contains("testmod::myns"), + "SLR output should contain combined namespace" + ); +} + +/// Log elision in release mode should remove all logs. +#[test] +fn log_elision_release_removes_all() { + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.log.trace(f"trace"); + @emit.log.debug(f"debug"); + @emit.log.info(f"info"); + @emit.log.warn(f"warn"); + @emit.log.error(f"error"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new_release(); + let slr_program = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr_program).expect("Should serialize"); + + assert!( + !json.contains("LogStmt"), + "Release mode should elide all logs" + ); +} + +/// Log elision with custom level should filter appropriately. +#[test] +fn log_elision_custom_level_filters() { + use zlup::codegen::SlrCodegen; + use zlup::codegen::slr::LogElisionLevel; + + let source = r#" + pub fn main() -> unit { + @emit.log.trace(f"trace"); + @emit.log.debug(f"debug"); + @emit.log.info(f"info"); + @emit.log.warn(f"warn"); + @emit.log.error(f"error"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_log_elision(LogElisionLevel(Some(200))); // INFO level + let slr_program = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr_program).expect("Should serialize"); + + // trace (0) and debug (10) should be elided + assert!(!json.contains("trace"), "trace should be elided"); + assert!(!json.contains("debug"), "debug should be elided"); + // info (20), warn (30), error (40) should remain + assert!(json.contains("info"), "info should remain"); + assert!(json.contains("warn"), "warn should remain"); + assert!(json.contains("error"), "error should remain"); +} + +// ============================================================================= +// Result Expression Tests +// ============================================================================= + +/// Result expression should parse and type check. +#[test] +fn result_expr_parses() { + let source = r#" + pub fn main() -> unit { + result("measurement", 42); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// Result expression with namespaced tag. +#[test] +fn result_expr_namespaced_tag() { + let source = r#" + pub fn main() -> unit { + result("qec/syndrome", 0); + result("qec/round_1/parity", true); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// Result expression with various value types. +#[test] +fn result_expr_various_types() { + let source = r#" + pub fn main() -> unit { + result("int_result", 42); + result("bool_result", true); + result("float_result", 3.14); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +// ============================================================================= +// Simulator Control Expression Tests +// ============================================================================= + +/// @emit.sim.send should parse. +#[test] +fn sim_send_parses() { + let source = r#" + pub fn main() -> unit { + @emit.sim.send("checkpoint", "before_correction"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// @emit.sim.send with various value types. +#[test] +fn sim_send_various_values() { + let source = r#" + pub fn main() -> unit { + @emit.sim.send("seed", 12345); + @emit.sim.send("checkpoint", "start"); + @emit.sim.send("noise_rate", 0.01); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// @emit.sim.noise_enable should parse. +#[test] +fn sim_noise_enable_parses() { + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_enable(); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// @emit.sim.noise_disable should parse. +#[test] +fn sim_noise_disable_parses() { + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_disable(); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// Multiple sim commands in sequence should work. +#[test] +fn sim_multiple_commands() { + let source = r#" + pub fn main() -> unit { + @emit.sim.send("seed", 42); + @emit.sim.send("noise_model", "depolarizing"); + @emit.sim.send("checkpoint", "start"); + q := qalloc(1); + h q[0]; + @emit.sim.noise_disable(); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +// ============================================================================= +// SLR Codegen Tests for Result and Sim +// ============================================================================= + +/// Result expressions should generate SendStmt with channel "result". +#[test] +fn result_generates_slr() { + use zlup::codegen::slr::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + result("answer", 42); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify SendStmt with channel "result" is in output + assert!( + json.contains("SendStmt"), + "Should contain SendStmt: {}", + json + ); + assert!( + json.contains("\"channel\": \"result\""), + "Should have result channel: {}", + json + ); + assert!( + json.contains("\"key\": \"answer\""), + "Should contain key: {}", + json + ); +} + +/// Sim commands should generate SendStmt with channel "sim" for simulator target. +#[test] +fn sim_generates_slr_for_simulator() { + use zlup::codegen::slr::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_enable(); + @emit.sim.send("seed", 42); + @emit.sim.noise_disable(); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + // sim_mode defaults to Emit (simulator target) + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify SendStmt with channel "sim" is in output + assert!( + json.contains("SendStmt"), + "Should contain SendStmt: {}", + json + ); + assert!( + json.contains("\"channel\": \"sim\""), + "Should have sim channel: {}", + json + ); + assert!( + json.contains("\"key\": \"noise_enable\""), + "Should contain noise_enable: {}", + json + ); + assert!( + json.contains("\"key\": \"seed\""), + "Should contain seed key: {}", + json + ); +} + +/// Sim commands should emit barriers for hardware target (default). +#[test] +fn sim_emits_barrier_for_hardware() { + use zlup::codegen::slr::{SimMode, SlrCodegen}; + + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_enable(); + @emit.sim.send("checkpoint", "start"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_sim_mode(SimMode::Barrier); // Hardware target default + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify sim SendStmt is NOT in output, but BarrierOp IS + assert!( + !json.contains("\"channel\": \"sim\""), + "Should NOT contain sim channel for hardware: {}", + json + ); + assert!( + json.contains("BarrierOp"), + "Should contain BarrierOp for ordering: {}", + json + ); +} + +/// Sim commands can be completely elided with explicit opt-in. +#[test] +fn sim_fully_elided_when_requested() { + use zlup::codegen::slr::{SimMode, SlrCodegen}; + + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_enable(); + @emit.sim.send("checkpoint", "start"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_sim_mode(SimMode::Elide); // Explicit full elision + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify neither sim SendStmt nor BarrierOp in output + assert!( + !json.contains("\"channel\": \"sim\""), + "Should NOT contain sim channel: {}", + json + ); + assert!( + !json.contains("BarrierOp"), + "Should NOT contain BarrierOp: {}", + json + ); +} + +/// Sim barrier is scoped to allocators in current scope. +#[test] +fn sim_barrier_is_scoped_to_allocators() { + use zlup::codegen::slr::{SimMode, SlrCodegen}; + + let source = r#" + pub fn main() -> unit { + q := qalloc(4); + @emit.sim.noise_disable(); + h q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_sim_mode(SimMode::Barrier); + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify BarrierOp includes the allocator "q" + assert!( + json.contains("BarrierOp"), + "Should contain BarrierOp: {}", + json + ); + assert!( + json.contains("\"allocators\""), + "Should have allocators field: {}", + json + ); + assert!( + json.contains("\"q\""), + "Should include allocator 'q': {}", + json + ); +} + +/// Result is NEVER elided, even with full log elision in release mode. +#[test] +fn result_never_elided_in_release() { + use zlup::codegen::slr::{SimMode, SlrCodegen}; + + let source = r#" + pub fn main() -> unit { + @emit.log.debug(f"this will be elided"); + result("answer", 42); + @emit.sim.send("checkpoint", "end"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + + // Configure for hardware release (max elision) + let mut codegen = SlrCodegen::new_release(); + codegen.set_sim_mode(SimMode::Elide); // Full sim elision + + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Log should be elided + assert!(!json.contains("LogStmt"), "Log should be elided: {}", json); + // Sim should be elided + assert!( + !json.contains("\"channel\": \"sim\""), + "Sim should be elided: {}", + json + ); + // Result should ALWAYS be present + assert!( + json.contains("SendStmt"), + "Result should be present: {}", + json + ); + assert!( + json.contains("\"channel\": \"result\""), + "Result channel should be present: {}", + json + ); + assert!( + json.contains("\"key\": \"answer\""), + "Result key should be present: {}", + json + ); +} + +/// All three channels work together in a realistic program. +#[test] +fn all_channels_together() { + use zlup::codegen::slr::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + // Simulator setup + @emit.sim.send("seed", 42); + @emit.sim.send("noise_model", "depolarizing"); + + // Allocate qubits + q := qalloc(2); + @emit.log.info(f"allocated qubits"); + + // Quantum operations + h q[0]; + @emit.sim.noise_disable(); + cx (q[0], q[1]); + + // Measure and emit result + m := mz([2]u1) [q[0], q[1]]; + @emit.log.debug(f"measured: {m}"); + result("bell_measurement", m); + + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer + .analyze(&program) + .expect("Should pass semantic analysis"); + + let mut codegen = SlrCodegen::new(); + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // All three channel types should be present + assert!( + json.contains("LogStmt"), + "Should have log statements: {}", + json + ); + assert!( + json.contains("\"channel\": \"result\""), + "Should have result channel: {}", + json + ); + assert!( + json.contains("\"channel\": \"sim\""), + "Should have sim channel: {}", + json + ); +} + +// ============================================================================= +// Swap Builtin Tests +// ============================================================================= + +/// @swap should parse correctly. +#[test] +fn swap_builtin_parses() { + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + @swap(&a, &b); + return unit; + } + "#; + let result = zlup::parse(source); + assert!(result.is_ok(), "@swap should parse: {:?}", result.err()); +} + +/// @swap should pass semantic analysis. +#[test] +fn swap_builtin_semantic_analysis() { + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + @swap(&a, &b); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "@swap should pass semantic analysis: {:?}", + result.err() + ); +} + +/// @swap with wrong number of arguments should fail. +#[test] +fn swap_wrong_arg_count_rejected() { + let source = r#" + pub fn main() -> unit { + a := 1; + @swap(&a); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "@swap with 1 arg should fail"); +} + +/// @swap with mismatched types should fail. +#[test] +fn swap_type_mismatch_rejected() { + let source = r#" + pub fn main() -> unit { + a: i64 = 1; + b: f64 = 2.0; + @swap(&a, &b); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "@swap with mismatched types should fail"); +} + +/// @swap should generate SLR SwapOp. +#[test] +fn swap_generates_slr() { + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + @swap(&a, &b); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + assert!(json.contains("SwapOp"), "Should contain SwapOp: {}", json); +} + +// ============================================================================= +// Reference Safety Tests (safe-by-constraint memory model) +// ============================================================================= + +/// Returning a reference to a local variable is always rejected (safe-by-constraint). +#[test] +fn return_reference_to_local_rejected() { + let source = r#" + fn bad() -> *i64 { + x := 42; + return &x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Returning reference to local should fail"); + let err = result.unwrap_err(); + assert!( + format!("{}", err).contains("local variable"), + "Error should mention local variable: {}", + err + ); +} + +/// Returning a reference to a local is rejected even in permissive mode (no escape hatch). +#[test] +fn return_reference_to_local_rejected_permissive() { + let source = r#" + fn bad() -> *i64 { + x := 42; + return &x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + let result = analyzer.analyze(&program); + // Safe-by-constraint: no escape hatch for returning dangling references + assert!( + result.is_err(), + "Returning reference to local should fail even in permissive mode" + ); +} + +/// Returning a parameter reference should be allowed (caller owns the data). +#[test] +fn return_reference_to_param_allowed() { + let source = r#" + fn ok(x: *i32) -> *i32 { + return x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); // strict mode + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Returning parameter reference should be allowed: {:?}", + result.err() + ); +} + +/// Returning a value (not reference) of a local is fine. +#[test] +fn return_value_of_local_allowed() { + let source = r#" + fn ok() -> i32 { + x := 42; + return x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); // strict mode + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Returning value of local should be allowed: {:?}", + result.err() + ); +} + +/// Returning a slice of a local array should be rejected. +#[test] +fn return_slice_of_local_rejected() { + let source = r#" + fn bad() -> []i32 { + arr: [4]i32 = [1, 2, 3, 4]; + return arr[0..2]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Returning slice of local array should fail" + ); +} + +/// Slice syntax with open-ended ranges should parse correctly. +#[test] +fn slice_open_ended_parses() { + // arr[..n] - from start to n + let source1 = r#" + fn test() -> unit { + arr: [4]i32 = [1, 2, 3, 4]; + x := arr[..2]; + return unit; + } + "#; + assert!(zlup::parse(source1).is_ok(), "arr[..2] should parse"); + + // arr[n..] - from n to end + let source2 = r#" + fn test() -> unit { + arr: [4]i32 = [1, 2, 3, 4]; + x := arr[2..]; + return unit; + } + "#; + assert!(zlup::parse(source2).is_ok(), "arr[2..] should parse"); + + // arr[..] - entire slice + let source3 = r#" + fn test() -> unit { + arr: [4]i32 = [1, 2, 3, 4]; + x := arr[..]; + return unit; + } + "#; + assert!(zlup::parse(source3).is_ok(), "arr[..] should parse"); +} + +/// Slice of parameter array is allowed (caller owns it). +#[test] +fn slice_of_param_allowed() { + let source = r#" + fn ok(arr: []i32) -> []i32 { + return arr[0..2]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // Slicing a parameter is safe - the caller owns the data + assert!( + result.is_ok(), + "Slicing parameter array should be allowed: {:?}", + result.err() + ); +} + +/// Returning a tuple containing a reference to local should be rejected. +#[test] +fn return_tuple_with_local_reference_rejected() { + let source = r#" + fn bad() -> (i32, *i64) { + x := 42; + return (1, &x); + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Returning tuple with reference to local should fail" + ); +} + +/// Returning a struct containing a reference to local should be rejected. +#[test] +fn return_struct_with_local_reference_rejected() { + let source = r#" + Wrapper := struct { ptr: *i64 }; + fn bad() -> Wrapper { + x := 42; + return Wrapper { ptr: &x }; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Returning struct with reference to local should fail" + ); +} + +/// Returning an array containing references to locals should be rejected. +#[test] +fn return_array_with_local_references_rejected() { + let source = r#" + fn bad() -> [2]*i64 { + x := 1; + y := 2; + return [&x, &y]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Returning array with references to locals should fail" + ); +} + +/// Taking address of parameter and returning is allowed (caller owns it). +#[test] +fn return_address_of_param_allowed() { + let source = r#" + fn ok(x: i32) -> *i32 { + return &x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // Parameters are owned by caller, so this is safe + // (the reference is valid for the caller's scope) + assert!( + result.is_ok(), + "Returning address of parameter should be allowed: {:?}", + result.err() + ); +} + +// ============================================================================= +// @swap Additional Safety Tests +// ============================================================================= + +/// @swap requires pointer arguments. +#[test] +fn swap_requires_pointers() { + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + @swap(a, b); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "@swap without & should fail"); +} + +/// @swap with three arguments should fail. +#[test] +fn swap_too_many_args_rejected() { + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + c := 3; + @swap(&a, &b, &c); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "@swap with 3 args should fail"); +} + +// ============================================================================= +// Qubit Safety Tests +// ============================================================================= + +/// Measuring the same qubit twice in a tick should be rejected (strict mode). +#[test] +fn duplicate_qubit_in_tick_measurement_rejected() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + pz q[0]; + pz q[1]; + tick { + mz(u1) q[0]; + mz(u1) q[0]; + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Duplicate qubit in tick should fail in strict mode" + ); +} + +/// Using the same qubit twice in different gates within a tick should be rejected. +#[test] +fn duplicate_qubit_in_tick_gates_rejected() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + pz q[0]; + pz q[1]; + tick { + h q[0]; + x q[0]; + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Using same qubit in multiple gates within tick should fail" + ); +} + +/// Gate on unprepared qubit should be rejected in strict mode. +#[test] +fn gate_on_unprepared_qubit_rejected() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + h q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Gate on unprepared qubit should fail in strict mode" + ); +} + +/// Gate after pz (prepare) should succeed. +#[test] +fn gate_after_prepare_allowed() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + pz q[0]; + h q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Gate after prepare should succeed: {:?}", + result.err() + ); +} + +/// Mixed gate and measurement on same qubit in tick should be rejected. +#[test] +fn mixed_gate_measurement_same_qubit_in_tick_rejected() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + pz q[0]; + pz q[1]; + tick { + h q[0]; + mz(u1) q[0]; + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Gate and measurement on same qubit in tick should fail" + ); +} + +/// Different qubits in tick operations should succeed. +#[test] +fn different_qubits_in_tick_allowed() { + let source = r#" + pub fn main() -> unit { + q := qalloc(4); + pz q[0]; + pz q[1]; + pz q[2]; + pz q[3]; + tick { + h q[0]; + x q[1]; + mz(u1) q[2]; + mz(u1) q[3]; + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Different qubits in tick should succeed: {:?}", + result.err() + ); +} + +// ============================================================================= +// Additional Slice Syntax Tests +// ============================================================================= + +/// Slice with variable bounds should parse. +#[test] +fn slice_with_variable_bounds_parses() { + let source = r#" + fn test(start: usize, end: usize) -> unit { + arr: [10]i32 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + x := arr[start..end]; + return unit; + } + "#; + assert!( + zlup::parse(source).is_ok(), + "Slice with variable bounds should parse" + ); +} + +/// Slice with expression bounds should parse. +#[test] +fn slice_with_expression_bounds_parses() { + let source = r#" + fn test() -> unit { + arr: [10]i32 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + n := 5; + x := arr[n - 2..n + 2]; + return unit; + } + "#; + assert!( + zlup::parse(source).is_ok(), + "Slice with expression bounds should parse" + ); +} + +/// Slice used in assignment should parse. +#[test] +fn slice_assignment_parses() { + let source = r#" + fn test() -> unit { + arr: [4]i32 = [1, 2, 3, 4]; + slice := arr[1..3]; + return unit; + } + "#; + let result = zlup::parse(source); + assert!( + result.is_ok(), + "Slice assignment should parse: {:?}", + result.err() + ); +} + +/// Multiple slicing operations should parse. +#[test] +fn multiple_slices_parse() { + let source = r#" + fn test() -> unit { + arr: [10]i32 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + a := arr[0..3]; + b := arr[3..6]; + c := arr[6..]; + d := arr[..4]; + return unit; + } + "#; + let result = zlup::parse(source); + assert!( + result.is_ok(), + "Multiple slices should parse: {:?}", + result.err() + ); +} + +// ============================================================================= +// Slice Type Semantics Tests +// ============================================================================= + +/// Re-slicing a slice should return a slice type. +/// NOTE: Variable names must not be gate names (s, h, x, y, z, t are gates). +#[test] +fn reslice_returns_slice_type() { + let source = r#" + fn reslice(data: []i32) -> []i32 { + return data[1..3]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Re-slicing should return slice type: {:?}", + result.err() + ); +} + +/// Indexing a slice with an integer should return the element type. +/// NOTE: Variable names must not be gate names (s, h, x, y, z, t are gates). +#[test] +fn slice_index_returns_element_type() { + let source = r#" + fn get_element(data: []i32) -> i32 { + return data[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Slice index should return element type: {:?}", + result.err() + ); +} + +/// Chained slicing: arr[1..5][0..2] should work. +#[test] +fn chained_slicing_allowed() { + let source = r#" + fn chain(arr: []i32) -> []i32 { + return arr[1..5][0..2]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Chained slicing should be allowed: {:?}", + result.err() + ); +} + +/// Array type [N]T is distinct from slice type []T. +#[test] +fn array_type_distinct_from_slice() { + // Returning array when slice expected should fail + let source = r#" + fn bad() -> []i32 { + arr: [3]i32 = [1, 2, 3]; + return arr; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // This should fail because [3]i32 is not []i32 + assert!( + result.is_err(), + "Array should not be assignable to slice type" + ); +} + +/// Slicing an array produces a slice type. +#[test] +fn slicing_array_produces_slice() { + let source = r#" + fn to_slice(arr: [5]i32) -> []i32 { + return arr[0..3]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Slicing array should produce slice: {:?}", + result.err() + ); +} + +/// Nested slice types: [][]i32 should work. +#[test] +fn nested_slice_type_allowed() { + let source = r#" + fn nested(matrix: [][]i32) -> []i32 { + return matrix[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Nested slice types should be allowed: {:?}", + result.err() + ); +} + +/// Open-ended slice of parameter is allowed. +#[test] +fn open_slice_of_param_allowed() { + let source = r#" + fn full_slice(arr: []i32) -> []i32 { + return arr[..]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Full slice of param should be allowed: {:?}", + result.err() + ); +} + +/// Slice with start index only. +#[test] +fn slice_from_start_index() { + let source = r#" + fn from_start(arr: []i32) -> []i32 { + return arr[2..]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Slice from start index should work: {:?}", + result.err() + ); +} + +/// Slice with end index only. +#[test] +fn slice_to_end_index() { + let source = r#" + fn to_end(arr: []i32) -> []i32 { + return arr[..5]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Slice to end index should work: {:?}", + result.err() + ); +} + +// ============================================================================= +// Gate Naming Tests +// ============================================================================= + +/// The old 's' gate name should no longer be recognized. +/// 's' is now parsed as a variable identifier, not a gate. +/// Verifies that 's' is no longer valid gate syntax (removed from grammar). +#[test] +fn old_s_gate_name_not_recognized() { + let source = r#" + fn test() -> unit { + q := qalloc(1); + pz q; + s q[0]; + return unit; + } + "#; + // 's' is no longer a valid gate keyword in the grammar, so parsing fails + assert!(zlup::parse(source).is_err()); +} + +/// The 'sz' gate should work correctly. +#[test] +fn sz_gate_works() { + let source = r#" + fn test() -> unit { + q := qalloc(1); + pz q; + sz q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "sz gate should work: {:?}", result.err()); +} + +/// The 'szdg' gate should work correctly. +#[test] +fn szdg_gate_works() { + let source = r#" + fn test() -> unit { + q := qalloc(1); + pz q; + szdg q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "szdg gate should work: {:?}", result.err()); +} + +/// Variable named 's' should now be allowed (no longer a gate). +#[test] +fn variable_named_s_allowed() { + let source = r#" + fn test() -> i32 { + s: i32 = 42; + return s; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Variable 's' should be allowed: {:?}", + result.err() + ); +} + +// ============================================================================= +// Optimizer Gate Cancellation Tests +// ============================================================================= + +/// SZ and SZdg should cancel each other. +#[test] +fn optimizer_sz_szdg_cancellation() { + use zlup::optimize::Optimizer; + + let source = r#" + pub fn main() -> unit { + q := qalloc(1); + pz q; + sz q[0]; + szdg q[0]; + return unit; + } + "#; + let ast = zlup::parse(source).expect("Should parse"); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats().gates_cancelled, + 2, + "SZ and SZdg should cancel" + ); +} + +/// SZdg followed by SZ should also cancel. +#[test] +fn optimizer_szdg_sz_cancellation() { + use zlup::optimize::Optimizer; + + let source = r#" + pub fn main() -> unit { + q := qalloc(1); + pz q; + szdg q[0]; + sz q[0]; + return unit; + } + "#; + let ast = zlup::parse(source).expect("Should parse"); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats().gates_cancelled, + 2, + "SZdg and SZ should cancel" + ); +} + +// ============================================================================= +// Slice Edge Cases Tests +// ============================================================================= + +/// Triple-chained slicing should work. +#[test] +fn triple_chained_slicing() { + let source = r#" + fn triple(arr: []i32) -> []i32 { + return arr[0..10][2..8][1..4]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Triple-chained slicing should work: {:?}", + result.err() + ); +} + +/// Deeply nested slice types should work. +#[test] +fn deeply_nested_slice_types() { + let source = r#" + fn nested3d(cube: [][][]i32) -> [][]i32 { + return cube[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "3D nested slice types should work: {:?}", + result.err() + ); +} + +/// Indexing 3D nested slice returns 2D slice. +#[test] +fn nested_slice_indexing_returns_correct_type() { + let source = r#" + fn get_row(matrix: [][]i32) -> []i32 { + return matrix[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Indexing 2D slice should return 1D slice: {:?}", + result.err() + ); +} + +/// Slicing then indexing should work. +#[test] +fn slice_then_index() { + let source = r#" + fn slice_index(arr: []i32) -> i32 { + return arr[1..5][0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Slice then index should work: {:?}", + result.err() + ); +} + +/// Indexing then slicing should work on nested types. +#[test] +fn index_then_slice_nested() { + let source = r#" + fn index_slice(matrix: [][]i32) -> []i32 { + return matrix[0][1..5]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Index then slice on nested should work: {:?}", + result.err() + ); +} + +// ============================================================================= +// Array vs Slice Coercion Tests +// ============================================================================= + +/// Returning fixed array where slice expected should fail. +#[test] +fn array_not_coercible_to_slice() { + let source = r#" + fn bad() -> []i32 { + arr: [5]i32 = [1, 2, 3, 4, 5]; + return arr; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Array should not be directly coercible to slice" + ); +} + +/// Slicing an array parameter to get a slice should work. +/// NOTE: Cannot slice local array and return it (escape analysis prevents this). +#[test] +fn array_sliced_to_slice() { + let source = r#" + fn ok(arr: [5]i32) -> []i32 { + return arr[..]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Array param sliced with [..] should produce slice: {:?}", + result.err() + ); +} + +/// Passing array where slice parameter expected should fail. +#[test] +fn array_param_not_coercible_to_slice_param() { + let source = r#" + fn takes_slice(data: []i32) -> unit { + return unit; + } + + fn caller() -> unit { + arr: [3]i32 = [1, 2, 3]; + takes_slice(arr); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_err(), + "Array should not be passable where slice expected" + ); +} + +/// Passing sliced array to slice parameter should work. +#[test] +fn sliced_array_to_slice_param() { + let source = r#" + fn takes_slice(data: []i32) -> unit { + return unit; + } + + fn caller() -> unit { + arr: [3]i32 = [1i32, 2i32, 3i32]; + takes_slice(arr[..]); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + result.is_ok(), + "Sliced array should be passable as slice: {:?}", + result.err() + ); +} + +// ============================================================================= +// Error Message Quality Tests +// ============================================================================= + +/// Type mismatch between array and slice should have clear error. +#[test] +fn array_slice_mismatch_error_message() { + let source = r#" + fn bad() -> []i32 { + arr: [3]i32 = [1, 2, 3]; + return arr; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + + match result { + Err(e) => { + let error_msg = format!("{:?}", e); + // Error should mention the type mismatch + assert!( + error_msg.contains("TypeMismatch") || error_msg.contains("mismatch"), + "Error should indicate type mismatch: {}", + error_msg + ); + } + Ok(_) => panic!("Should have failed with type mismatch"), + } +} + +/// Using gate name as variable in quantum context should give helpful error. +#[test] +fn gate_name_variable_in_quantum_context() { + // Using 'h' (Hadamard gate) as a variable name + let source = r#" + fn test() -> unit { + q := qalloc(1); + pz q; + h: i32 = 5; + return unit; + } + "#; + // This should parse but 'h' shadows the gate - the question is whether + // this causes issues. Let's just verify it parses and analyzes. + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // This might work or fail depending on implementation - we're testing behavior + // Either outcome is fine, we're checking it doesn't crash + let _ = result; +} + +/// Return type mismatch with slice element should be clear. +#[test] +fn slice_element_type_mismatch_error() { + let source = r#" + fn bad(arr: []i32) -> bool { + return arr[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + + assert!( + result.is_err(), + "Should fail: returning i32 where bool expected" + ); + if let Err(e) = result { + let error_msg = format!("{:?}", e); + assert!( + error_msg.contains("i32") || error_msg.contains("bool"), + "Error should mention the types involved: {}", + error_msg + ); + } +} diff --git a/exp/zlup/uv.lock b/exp/zlup/uv.lock new file mode 100644 index 000000000..733865d70 --- /dev/null +++ b/exp/zlup/uv.lock @@ -0,0 +1,618 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "csscompressor" +version = "0.9.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" } + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "htmlmin2" +version = "0.1.13" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/31/a76f4bfa885f93b8167cb4c85cf32b54d1f64384d0b897d45bc6d19b7b45/htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2", size = 34486, upload-time = "2023-03-14T21:28:30.388Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsmin" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" } + +[[package]] +name = "markdown" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocs-minify-plugin" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "csscompressor" }, + { name = "htmlmin2" }, + { name = "jsmin" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/67/fe4b77e7a8ae7628392e28b14122588beaf6078b53eb91c7ed000fd158ac/mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d", size = 8366, upload-time = "2024-01-29T16:11:32.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723, upload-time = "2024-01-29T16:11:31.851Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "zlup-docs" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mkdocs-minify-plugin" }, +] + +[package.metadata] +requires-dist = [ + { name = "mkdocs", specifier = ">=1.6" }, + { name = "mkdocs-material", specifier = ">=9.5" }, + { name = "mkdocs-minify-plugin", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] diff --git a/exp/zluppy/Cargo.toml b/exp/zluppy/Cargo.toml new file mode 100644 index 000000000..fb3dd3d93 --- /dev/null +++ b/exp/zluppy/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "zluppy-python" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Python bindings for Zluppy quantum programming language" +publish = false + +[lib] +name = "_zluppy" +crate-type = ["cdylib", "rlib"] +# Skip doc tests as they won't work properly in this setup +doctest = false +# Skip unit tests - all testing should be done through Python +test = false + +[dependencies] +# The zlup library crate (with HUGR support for compilation to HUGR) +zlup = { path = "../zlup", features = ["hugr"] } + +# PyO3 for Python bindings +pyo3 = { workspace = true, features = ["extension-module", "abi3-py310"] } + +# Serialization +serde_json.workspace = true + +[lints] +workspace = true diff --git a/exp/zluppy/README.md b/exp/zluppy/README.md new file mode 100644 index 000000000..d19d23c9e --- /dev/null +++ b/exp/zluppy/README.md @@ -0,0 +1,50 @@ +# Zluppy + +**EXPERIMENTAL** - Python bindings for the Zlup quantum programming language. + +Zlup is an experimental language exploring alternative syntax for quantum programs. +It complements Guppy (the primary quantum programming language in PECOS) and may +serve as a compilation target or alternative syntax for certain workflows. + +## Installation + +```bash +pip install zluppy +``` + +## Usage + +```python +import zluppy + +# Compile Zluppy source to SLR-AST (returns dict) +ast = zluppy.compile_to_slr( + """ + fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + } +""" +) + +# Compile to SLR-AST JSON string +json_str = zluppy.compile_to_slr_json(source) + +# Check source for errors +zluppy.check(source) # Raises ZluppyError on failure +zluppy.check(source, strict=True) # NASA Power of 10 mode + +# Build programs programmatically +# Note: SlrProgram uses uppercase gate names (SLR-AST convention) +# while Zlup source code uses lowercase (h, cx, etc.) +prog = zluppy.SlrProgram("main") +prog.add_allocator("q", 2) +prog.add_gate("H", [("q", 0)]) +prog.add_gate("CX", [("q", 0), ("q", 1)]) +json_str = prog.to_json() +``` + +## License + +Apache-2.0 diff --git a/exp/zluppy/osv-scanner.toml b/exp/zluppy/osv-scanner.toml new file mode 100644 index 000000000..97059f7e2 --- /dev/null +++ b/exp/zluppy/osv-scanner.toml @@ -0,0 +1,12 @@ +# OSV-Scanner configuration for exp/zluppy (config discovery is per-directory: +# the root osv-scanner.toml does not apply to this uv.lock). +# Same ignore-list policy as the root osv-scanner.toml: only entries we cannot +# fix from this repo, each documenting crate@version, chain, and upstream owner. + +[[IgnoredVulns]] +id = "PYSEC-2026-151" +# wasmtime (PyPI) 38.0.0 -- Wasmtime stack-overflow bug class; the advisory +# lists NO fixed release (last_affected 43.0.0, the latest). +# Chain: zluppy dev/test group -> quantum-pecos -> guppylang -> guppylang-internals -> wasmtime. +# Upstream owner: https://github.com/bytecodealliance/wasmtime-py. +reason = "Transitive via guppylang; no fixed wasmtime-py release exists yet. PECOS only runs trusted, locally-generated Wasm through it. Re-check on guppylang bumps." diff --git a/exp/zluppy/pyproject.toml b/exp/zluppy/pyproject.toml new file mode 100644 index 000000000..2b2f3d0af --- /dev/null +++ b/exp/zluppy/pyproject.toml @@ -0,0 +1,62 @@ +[project] +name = "zluppy" +version = "0.1.0" +description = "Python bindings for Zluppy quantum programming language" +authors = [ + {name = "The PECOS Developers"}, +] +maintainers =[ + {name = "Ciaran Ryan-Anderson", email = "ciaranra@gmail.com"}, +] +dependencies = [] +readme = "README.md" +requires-python = ">= 3.10" +license = "Apache-2.0" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Physics", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Rust", +] + +[build-system] +requires = ["maturin>=1.2,<2.0"] +build-backend = "maturin" + +[tool.maturin] +module-name = "zluppy._zluppy" +python-source = "python" + +[dependency-groups] +dev = [ + "patchelf; platform_system != 'Windows'", + "pytest>=9.0.2", + "quantum-pecos", +] +test = [ + "pytest>=7.0", + "quantum-pecos", +] + +[tool.uv.sources] +zluppy = { workspace = true } +quantum-pecos = { path = "../../python/quantum-pecos", editable = true } + +[tool.pytest.ini_options] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] + +[tool.ruff] +lint.extend-select = ["S", "B", "PT"] +lint.ignore = ["S101"] diff --git a/exp/zluppy/python/zluppy/__init__.py b/exp/zluppy/python/zluppy/__init__.py new file mode 100644 index 000000000..f270c8975 --- /dev/null +++ b/exp/zluppy/python/zluppy/__init__.py @@ -0,0 +1,176 @@ +"""Zluppy: A Zig/SLR/NASA Power of 10 quantum programming language. + +This module provides Python bindings for compiling and running Zluppy programs. + +Example: + >>> import zluppy + >>> from pecos import hugr_engine + >>> + >>> # Compile and run a Bell state program + >>> hugr_bytes = ( + ... zluppy.ZluppyEngine() + ... .source( + ... ''' + ... fn main() -> void { + ... var q = qalloc(2); + ... H(q[0]); + ... CX(q[0], q[1]); + ... } + ... ''' + ... ) + ... .to_hugr_bytes() + ... ) + >>> + >>> result = hugr_engine().hugr_bytes(hugr_bytes).to_sim().run(shots=100) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +# Re-export everything from the Rust extension +from zluppy._zluppy import ( + ZluppyError, + compile_to_slr, + compile_to_slr_json, + compile_to_hugr, + compile_file, + compile_file_json, + compile_file_hugr, + check, + check_file, + parse_debug, + version, + SlrProgram, + ZlupProgram, +) + +# Import the Rust ZluppyEngine for wrapping +from zluppy._zluppy import ZluppyEngine as _RustZluppyEngine + +if TYPE_CHECKING: + from pecos_rslib import ShotVec + +__all__ = [ + # Exception + "ZluppyError", + # Source compilation + "compile_to_slr", + "compile_to_slr_json", + "compile_to_hugr", + "check", + "parse_debug", + # File compilation + "compile_file", + "compile_file_json", + "compile_file_hugr", + "check_file", + # Utilities + "version", + # Classes + "SlrProgram", + "ZlupProgram", + "ZluppyEngine", +] + + +class ZluppyEngine: + """Engine for compiling and running Zluppy programs. + + Wraps the Rust ZluppyEngine and adds convenience methods for running + through PECOS's hugr_engine. + + Example: + >>> result = ( + ... zluppy.ZluppyEngine() + ... .source( + ... ''' + ... fn main() -> void { + ... var q = qalloc(2); + ... H(q[0]); + ... CX(q[0], q[1]); + ... } + ... ''' + ... ) + ... .run(shots=100) + ... ) + >>> print(result.to_dict()) + + Or with explicit steps: + >>> engine = zluppy.ZluppyEngine().file("bell.zlp") + >>> hugr_bytes = engine.to_hugr_bytes() + >>> result = hugr_engine().hugr_bytes(hugr_bytes).to_sim().run(shots=100) + """ + + def __init__(self, strict: bool = False) -> None: + """Create a new ZluppyEngine. + + Args: + strict: Enable strict mode (NASA Power of 10 checks). + """ + self._rust_engine = _RustZluppyEngine(strict) + + def source(self, code: str) -> ZluppyEngine: + """Compile Zluppy source code. + + Args: + code: Zluppy source code as a string. + + Returns: + self for method chaining. + + Raises: + ZluppyError: If parsing, semantic analysis, or codegen fails. + """ + self._rust_engine = self._rust_engine.source(code) + return self + + def file(self, path: str) -> ZluppyEngine: + """Compile a .zlp file. + + Args: + path: Path to a .zlp file. + + Returns: + self for method chaining. + + Raises: + IOError: If the file cannot be read. + ZluppyError: If parsing, semantic analysis, or codegen fails. + """ + self._rust_engine = self._rust_engine.file(path) + return self + + def to_hugr_bytes(self) -> bytes: + """Return the compiled HUGR bytes. + + Returns: + HUGR in binary envelope format, suitable for hugr_engine(). + + Raises: + ValueError: If no source has been compiled. + """ + return self._rust_engine.to_hugr_bytes() + + def run(self, shots: int = 1) -> ShotVec: + """Run the compiled program through the simulator. + + Convenience method that calls PECOS's hugr_engine with the compiled HUGR. + + Args: + shots: Number of shots to run. + + Returns: + The simulation result from PECOS. + + Raises: + ValueError: If no source has been compiled. + ImportError: If pecos is not installed. + """ + from pecos import hugr_engine + + hugr_bytes = self.to_hugr_bytes() + return hugr_engine().hugr_bytes(hugr_bytes).to_sim().run(shots=shots) + + def __repr__(self) -> str: + return repr(self._rust_engine) diff --git a/exp/zluppy/src/lib.rs b/exp/zluppy/src/lib.rs new file mode 100644 index 000000000..3127d8110 --- /dev/null +++ b/exp/zluppy/src/lib.rs @@ -0,0 +1,1163 @@ +//! Python bindings for Zluppy via `PyO3`. +//! +//! This module provides Python access to Zluppy's compiler functionality: +//! +//! ```python +//! import zluppy +//! +//! # Compile to SLR-AST (returns dict) +//! ast = zluppy.compile_to_slr(""" +//! fn main() -> void { +//! var q = qalloc(2); +//! H(q[0]); +//! CX(q[0], q[1]); +//! } +//! """) +//! +//! # Compile to SLR-AST JSON string +//! json_str = zluppy.compile_to_slr_json(source) +//! +//! # Check source for errors +//! zluppy.check(source) # Raises ZluppyError on failure +//! zluppy.check(source, strict=True) # NASA Power of 10 mode +//! +//! # Parse and get AST as string (for debugging) +//! ast_str = zluppy.parse_debug(source) +//! ``` + +use std::path::Path; + +use pyo3::exceptions::{PyIOError, PyValueError}; +use pyo3::prelude::*; + +use ::zlup::codegen::{HugrCodegen, SlrCodegen}; +use ::zlup::semantic::SemanticAnalyzer; + +// ============================================================================= +// Error Types +// ============================================================================= + +pyo3::create_exception!(_zluppy, ZluppyError, pyo3::exceptions::PyException); + +/// Convert a parse error to a Python exception. +// By-value error adapter used directly as `Result::map_err(parse_error_to_py)`, +// which requires `FnOnce(E) -> PyErr`; `&E` would force a closure at every call site. +#[allow(clippy::needless_pass_by_value)] +fn parse_error_to_py(e: ::zlup::parser::ParseError) -> PyErr { + ZluppyError::new_err(format!( + "Parse error at {}:{}: {}", + e.location.line, e.location.column, e.message + )) +} + +/// Convert a semantic error to a Python exception. +// By-value error adapter used directly as `Result::map_err(semantic_error_to_py)`, +// which requires `FnOnce(E) -> PyErr`; `&E` would force a closure at every call site. +#[allow(clippy::needless_pass_by_value)] +fn semantic_error_to_py(e: ::zlup::semantic::SemanticError) -> PyErr { + ZluppyError::new_err(format!("Semantic error: {e}")) +} + +/// Convert a codegen error to a Python exception. +// By-value error adapter used directly as `Result::map_err(codegen_error_to_py)`, +// which requires `FnOnce(E) -> PyErr`; `&E` would force a closure at every call site. +#[allow(clippy::needless_pass_by_value)] +fn codegen_error_to_py(e: ::zlup::codegen::slr::SlrError) -> PyErr { + ZluppyError::new_err(format!("Codegen error: {e}")) +} + +/// Convert a HUGR codegen error to a Python exception. +// By-value error adapter used directly as `Result::map_err(hugr_error_to_py)`, +// which requires `FnOnce(E) -> PyErr`; `&E` would force a closure at every call site. +#[allow(clippy::needless_pass_by_value)] +fn hugr_error_to_py(e: ::zlup::codegen::hugr::HugrError) -> PyErr { + ZluppyError::new_err(format!("HUGR error: {e}")) +} + +// ============================================================================= +// Core Functions +// ============================================================================= + +/// Compile Zluppy source to SLR-AST and return as a Python dict. +/// +/// Args: +/// source: Zluppy source code as a string +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// dict: SLR-AST as a Python dictionary +/// +/// Raises: +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (source, strict = false))] +fn compile_to_slr(py: Python<'_>, source: &str, strict: bool) -> PyResult> { + // Parse + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // Code generation + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + // Convert to JSON then to Python dict + let json_str = codegen.to_json(&slr_program).map_err(codegen_error_to_py)?; + + // Parse JSON into Python object + let json_module = py.import("json")?; + let result = json_module.call_method1("loads", (json_str,))?; + Ok(result.into()) +} + +/// Compile Zluppy source to SLR-AST JSON string. +/// +/// Args: +/// source: Zluppy source code as a string +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// compact: Return compact JSON (no pretty-printing). Default: False +/// +/// Returns: +/// str: SLR-AST as a JSON string +/// +/// Raises: +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (source, strict = false, compact = false))] +fn compile_to_slr_json(source: &str, strict: bool, compact: bool) -> PyResult { + // Parse + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // Code generation + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + // Convert to JSON + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(codegen_error_to_py) + } else { + codegen.to_json(&slr_program).map_err(codegen_error_to_py) + } +} + +/// Check Zluppy source for errors without compiling. +/// +/// Args: +/// source: Zluppy source code as a string +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// None: If the source is valid +/// +/// Raises: +/// `ZluppyError`: If parsing or semantic analysis fails +#[pyfunction] +#[pyo3(signature = (source, strict = false))] +fn check(source: &str, strict: bool) -> PyResult<()> { + // Parse + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + Ok(()) +} + +/// Parse Zluppy source and return AST as debug string. +/// +/// This is primarily for debugging and inspection purposes. +/// +/// Args: +/// source: Zluppy source code as a string +/// +/// Returns: +/// str: AST in Rust Debug format +/// +/// Raises: +/// `ZluppyError`: If parsing fails +#[pyfunction] +fn parse_debug(source: &str) -> PyResult { + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + Ok(format!("{program:#?}")) +} + +/// Get the Zluppy version. +/// +/// Returns: +/// str: Version string +#[pyfunction] +fn version() -> &'static str { + ::zlup::VERSION +} + +// ============================================================================= +// File-based Functions +// ============================================================================= + +/// Read and return the contents of a Zluppy source file. +fn read_file(path: &str) -> PyResult { + std::fs::read_to_string(path) + .map_err(|e| PyIOError::new_err(format!("Failed to read {path}: {e}"))) +} + +/// Get the filename from a path for error reporting. +fn filename_from_path(path: &str) -> String { + Path::new(path) + .file_name() + .map_or_else(|| path.to_string(), |s| s.to_string_lossy().to_string()) +} + +/// Compile a Zluppy source file to SLR-AST and return as a Python dict. +/// +/// Args: +/// path: Path to a .zlp file +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// dict: SLR-AST as a Python dictionary +/// +/// Raises: +/// `IOError`: If the file cannot be read +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (path, strict = false))] +fn compile_file(py: Python<'_>, path: &str, strict: bool) -> PyResult> { + let source = read_file(path)?; + let filename = filename_from_path(path); + + // Parse with filename for better error messages + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // Code generation + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + // Convert to JSON then to Python dict + let json_str = codegen.to_json(&slr_program).map_err(codegen_error_to_py)?; + + let json_module = py.import("json")?; + let result = json_module.call_method1("loads", (json_str,))?; + Ok(result.into()) +} + +/// Compile a Zluppy source file to SLR-AST JSON string. +/// +/// Args: +/// path: Path to a .zlp file +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// compact: Return compact JSON (no pretty-printing). Default: False +/// +/// Returns: +/// str: SLR-AST as a JSON string +/// +/// Raises: +/// `IOError`: If the file cannot be read +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (path, strict = false, compact = false))] +fn compile_file_json(path: &str, strict: bool, compact: bool) -> PyResult { + let source = read_file(path)?; + let filename = filename_from_path(path); + + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(codegen_error_to_py) + } else { + codegen.to_json(&slr_program).map_err(codegen_error_to_py) + } +} + +/// Check a Zluppy source file for errors without compiling. +/// +/// Args: +/// path: Path to a .zlp file +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// None: If the source is valid +/// +/// Raises: +/// `IOError`: If the file cannot be read +/// `ZluppyError`: If parsing or semantic analysis fails +#[pyfunction] +#[pyo3(signature = (path, strict = false))] +fn check_file(path: &str, strict: bool) -> PyResult<()> { + let source = read_file(path)?; + let filename = filename_from_path(path); + + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + Ok(()) +} + +// ============================================================================= +// HUGR Compilation Functions +// ============================================================================= + +/// Compile Zluppy source to HUGR bytes. +/// +/// The returned bytes can be passed directly to `hugr_engine()` or `sim()`. +/// +/// Args: +/// source: Zluppy source code as a string +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// bytes: HUGR in binary envelope format +/// +/// Raises: +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (source, strict = false))] +fn compile_to_hugr( + py: Python<'_>, + source: &str, + strict: bool, +) -> PyResult> { + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + let bytes = codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?; + + Ok(pyo3::types::PyBytes::new(py, &bytes).into()) +} + +/// Compile a Zluppy source file to HUGR bytes. +/// +/// The returned bytes can be passed directly to `hugr_engine()` or `sim()`. +/// +/// Args: +/// path: Path to a .zlp file +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// bytes: HUGR in binary envelope format +/// +/// Raises: +/// `IOError`: If the file cannot be read +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (path, strict = false))] +fn compile_file_hugr( + py: Python<'_>, + path: &str, + strict: bool, +) -> PyResult> { + let source = read_file(path)?; + let filename = filename_from_path(path); + + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + let bytes = codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?; + + Ok(pyo3::types::PyBytes::new(py, &bytes).into()) +} + +// ============================================================================= +// SLR-AST Types (for direct construction) +// ============================================================================= + +/// SLR Program builder for constructing AST directly from Python. +/// +/// Example: +/// ```python +/// prog = zluppy.SlrProgram("main") +/// prog.add_allocator("q", 2) +/// prog.add_gate("H", [("q", 0)]) +/// prog.add_gate("CX", [("q", 0), ("q", 1)]) +/// json_str = prog.to_json() +/// ``` +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +struct SlrProgram { + inner: ::zlup::codegen::slr::SlrProgram, +} + +#[pymethods] +impl SlrProgram { + /// Create a new SLR program. + #[new] + fn new(name: &str) -> Self { + Self { + inner: ::zlup::codegen::slr::SlrProgram::new(name), + } + } + + /// Add an allocator declaration. + fn add_allocator(&mut self, name: &str, capacity: usize) { + let decl = ::zlup::codegen::slr::SlrAllocatorDecl::new(name, capacity); + if self.inner.allocator.is_none() { + self.inner.allocator = Some(decl.clone()); + } + self.inner + .declarations + .push(::zlup::codegen::slr::SlrDeclaration::Allocator(decl)); + } + + /// Add a gate operation. + /// + /// Args: + /// gate: Gate name (e.g., "H", "CX", "RZ") + /// targets: List of (`allocator_name`, index) tuples + /// params: Optional list of parameter values (for parameterized gates) + #[pyo3(signature = (gate, targets, params = None))] + fn add_gate( + &mut self, + gate: &str, + targets: Vec<(String, usize)>, + params: Option>, + ) -> PyResult<()> { + let gate_name = match gate { + // Single-qubit Pauli gates + "H" | "h" => "H", + "X" | "x" => "X", + "Y" | "y" => "Y", + "Z" | "z" => "Z", + // Square root gates (single-qubit) + "SX" | "sx" => "SX", + "SY" | "sy" => "SY", + "SZ" | "sz" => "SZ", + "SXdg" | "sxdg" => "SXdg", + "SYdg" | "sydg" => "SYdg", + "SZdg" | "szdg" => "SZdg", + // T gates + "T" | "t" => "T", + "Tdg" | "tdg" => "Tdg", + // F gates (Clifford face rotations) + "F" | "f" => "F", + "Fdg" | "fdg" => "Fdg", + "F4" | "f4" => "F4", + "F4dg" | "f4dg" => "F4dg", + // Two-qubit controlled gates + "CX" | "cx" => "CX", + "CY" | "cy" => "CY", + "CZ" | "cz" => "CZ", + "CH" | "ch" => "CH", + // Two-qubit Ising gates + "SXX" | "sxx" => "SXX", + "SYY" | "syy" => "SYY", + "SZZ" | "szz" => "SZZ", + "SXXdg" | "sxxdg" => "SXXdg", + "SYYdg" | "syydg" => "SYYdg", + "SZZdg" | "szzdg" => "SZZdg", + // Swap gates + "SWAP" | "swap" => "SWAP", + "iSWAP" | "iswap" => "iSWAP", + // Rotation gates (single-qubit, parameterized) + "RX" | "rx" => "RX", + "RY" | "ry" => "RY", + "RZ" | "rz" => "RZ", + // Rotation gates (two-qubit, parameterized) + "CRZ" | "crz" => "CRZ", + "RZZ" | "rzz" => "RZZ", + // Three-qubit gates + "CCX" | "ccx" => "CCX", + _ => { + return Err(PyValueError::new_err(format!("Unknown gate: {gate}"))); + } + }; + + let slot_refs: Vec<_> = targets + .into_iter() + .map(|(alloc, idx)| ::zlup::codegen::slr::SlrSlotRef::new(alloc, idx)) + .collect(); + + let param_exprs: Vec<_> = params + .unwrap_or_default() + .into_iter() + .map(|v| { + ::zlup::codegen::slr::SlrExpression::Literal( + ::zlup::codegen::slr::SlrLiteralExpr::float(v), + ) + }) + .collect(); + + let gate_op = + ::zlup::codegen::slr::SlrGateOp::new(gate_name, slot_refs).with_params(param_exprs); + self.inner + .body + .push(::zlup::codegen::slr::SlrStatement::Gate(gate_op)); + + Ok(()) + } + + /// Add a prepare operation (reset qubits to |0⟩). + /// + /// Args: + /// allocator: Allocator name + /// slots: Optional list of slot indices. If None, prepares all slots. + #[pyo3(signature = (allocator, slots = None))] + fn add_prepare(&mut self, allocator: &str, slots: Option>) { + let prepare_op = match slots { + Some(s) => ::zlup::codegen::slr::SlrPrepareOp::slots(allocator, s), + None => ::zlup::codegen::slr::SlrPrepareOp::all(allocator), + }; + self.inner + .body + .push(::zlup::codegen::slr::SlrStatement::Prepare(prepare_op)); + } + + /// Convert to JSON string. + #[pyo3(signature = (compact = false))] + fn to_json(&self, compact: bool) -> PyResult { + if compact { + serde_json::to_string(&self.inner) + .map_err(|e| PyValueError::new_err(format!("JSON error: {e}"))) + } else { + serde_json::to_string_pretty(&self.inner) + .map_err(|e| PyValueError::new_err(format!("JSON error: {e}"))) + } + } + + /// Convert to Python dict. + fn to_dict(&self, py: Python<'_>) -> PyResult> { + let json_str = self.to_json(false)?; + let json_module = py.import("json")?; + let result = json_module.call_method1("loads", (json_str,))?; + Ok(result.into()) + } + + fn __repr__(&self) -> String { + format!("SlrProgram(name={:?})", self.inner.name) + } +} + +// ============================================================================= +// ZlupProgram Builder (builds Zlup AST directly) +// ============================================================================= + +/// Builder for constructing Zlup programs programmatically. +/// +/// This builds the Zlup AST directly, which can then be compiled to +/// either SLR-AST or HUGR through the normal compilation pipeline. +/// +/// Example: +/// ```python +/// prog = zluppy.ZlupProgram("main") +/// prog.add_allocator("q", 2) +/// prog.add_gate("h", [("q", 0)]) +/// prog.add_gate("cx", [("q", 0), ("q", 1)]) +/// +/// # Compile to SLR +/// slr_json = prog.compile_to_slr() +/// +/// # Or compile to HUGR +/// hugr_bytes = prog.compile_to_hugr() +/// +/// # Or generate source code +/// source = prog.to_source() +/// ``` +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +struct ZlupProgram { + name: String, + statements: Vec<::zlup::ast::Stmt>, + strict: bool, +} + +#[pymethods] +impl ZlupProgram { + /// Create a new Zlup program builder. + /// + /// Args: + /// name: Program/function name (default: "main") + /// strict: Enable strict mode for compilation (default: False) + #[new] + #[pyo3(signature = (name = "main", strict = false))] + fn new(name: &str, strict: bool) -> Self { + Self { + name: name.to_string(), + statements: Vec::new(), + strict, + } + } + + /// Add a qubit allocator declaration. + /// + /// Args: + /// name: Allocator variable name + /// capacity: Number of qubits to allocate + /// + /// Returns: + /// self: For method chaining + fn add_allocator(&mut self, name: &str, capacity: usize) -> Self { + // Build: var {name} = qalloc({capacity}); + let alloc_call = ::zlup::ast::Expr::Call(Box::new(::zlup::ast::CallExpr { + callee: ::zlup::ast::Expr::Ident(::zlup::ast::Ident { + name: "qalloc".to_string(), + location: None, + }), + args: vec![::zlup::ast::Expr::IntLit(::zlup::ast::IntLit { + value: capacity as i128, + suffix: None, + location: None, + })], + location: None, + })); + + let binding = ::zlup::ast::Binding { + name: name.to_string(), + ty: None, + value: Some(alloc_call), + is_mutable: true, + is_pub: false, + doc_comment: None, + location: None, + }; + + self.statements.push(::zlup::ast::Stmt::Binding(binding)); + self.clone() + } + + /// Add a gate operation. + /// + /// Args: + /// gate: Gate name (e.g., "h", "cx", "rz") + /// targets: List of (`allocator_name`, index) tuples + /// params: Optional list of parameter values (for rotation gates) + /// + /// Returns: + /// self: For method chaining + #[pyo3(signature = (gate, targets, params = None))] + fn add_gate( + &mut self, + gate: &str, + targets: Vec<(String, usize)>, + params: Option>, + ) -> PyResult { + let gate_kind = match gate.to_lowercase().as_str() { + // Single-qubit Paulis + "x" => ::zlup::ast::GateKind::X, + "y" => ::zlup::ast::GateKind::Y, + "z" => ::zlup::ast::GateKind::Z, + "h" => ::zlup::ast::GateKind::H, + // Phase gates ("s"/"sz" and "sdg"/"szdg" are aliases for the same gate) + "s" | "sz" => ::zlup::ast::GateKind::SZ, + "sdg" | "szdg" => ::zlup::ast::GateKind::SZdg, + "t" => ::zlup::ast::GateKind::T, + "tdg" => ::zlup::ast::GateKind::Tdg, + // Square root gates + "sx" => ::zlup::ast::GateKind::SX, + "sy" => ::zlup::ast::GateKind::SY, + "sxdg" => ::zlup::ast::GateKind::SXdg, + "sydg" => ::zlup::ast::GateKind::SYdg, + // Rotation gates + "rx" => ::zlup::ast::GateKind::RX, + "ry" => ::zlup::ast::GateKind::RY, + "rz" => ::zlup::ast::GateKind::RZ, + // Two-qubit gates + "cx" => ::zlup::ast::GateKind::CX, + "cy" => ::zlup::ast::GateKind::CY, + "cz" => ::zlup::ast::GateKind::CZ, + "ch" => ::zlup::ast::GateKind::CH, + // Two-qubit Ising gates + "sxx" => ::zlup::ast::GateKind::SXX, + "syy" => ::zlup::ast::GateKind::SYY, + "szz" => ::zlup::ast::GateKind::SZZ, + "sxxdg" => ::zlup::ast::GateKind::SXXdg, + "syydg" => ::zlup::ast::GateKind::SYYdg, + "szzdg" => ::zlup::ast::GateKind::SZZdg, + "rzz" => ::zlup::ast::GateKind::RZZ, + // Face rotations + "f" => ::zlup::ast::GateKind::F, + "fdg" => ::zlup::ast::GateKind::Fdg, + "f4" => ::zlup::ast::GateKind::F4, + "f4dg" => ::zlup::ast::GateKind::F4dg, + _ => return Err(PyValueError::new_err(format!("Unknown gate: {gate}"))), + }; + + // Build slot references + let slot_refs: Vec<_> = targets + .into_iter() + .map(|(alloc, idx)| ::zlup::ast::SlotRef { + allocator: alloc, + index: Box::new(::zlup::ast::Expr::IntLit(::zlup::ast::IntLit { + value: idx as i128, + suffix: None, + location: None, + })), + location: None, + }) + .collect(); + + // Build parameter expressions + let param_exprs: Vec<_> = params + .unwrap_or_default() + .into_iter() + .map(|v| { + ::zlup::ast::Expr::FloatLit(::zlup::ast::FloatLit { + value: v, + suffix: None, + location: None, + }) + }) + .collect(); + + let gate_op = ::zlup::ast::GateOp { + kind: gate_kind, + targets: slot_refs, + params: param_exprs, + attrs: Vec::new(), + location: None, + }; + + self.statements.push(::zlup::ast::Stmt::Gate(gate_op)); + Ok(self.clone()) + } + + /// Add a prepare operation (reset qubits to |0⟩). + /// + /// Args: + /// allocator: Allocator name + /// slots: Optional list of slot indices. If None, prepares all slots. + /// + /// Returns: + /// self: For method chaining + #[pyo3(signature = (allocator, slots = None))] + fn add_prepare(&mut self, allocator: &str, slots: Option>) -> Self { + let prepare_op = ::zlup::ast::PrepareOp { + allocator: allocator.to_string(), + slots, + location: None, + }; + + self.statements.push(::zlup::ast::Stmt::Prepare(prepare_op)); + self.clone() + } + + /// Add a measure operation. + /// + /// Args: + /// targets: List of (`allocator_name`, index) tuples to measure + /// + /// Returns: + /// self: For method chaining + fn add_measure(&mut self, targets: Vec<(String, usize)>) -> Self { + let slot_refs: Vec<_> = targets + .into_iter() + .map(|(alloc, idx)| ::zlup::ast::SlotRef { + allocator: alloc, + index: Box::new(::zlup::ast::Expr::IntLit(::zlup::ast::IntLit { + value: idx as i128, + suffix: None, + location: None, + })), + location: None, + }) + .collect(); + + let measure_op = ::zlup::ast::MeasureOp { + targets: slot_refs, + results: Vec::new(), // No explicit result register + location: None, + }; + + self.statements.push(::zlup::ast::Stmt::Measure(measure_op)); + self.clone() + } + + /// Compile to SLR-AST JSON. + /// + /// Args: + /// compact: Return compact JSON (default: False) + /// + /// Returns: + /// str: SLR-AST as JSON string + #[pyo3(signature = (compact = false))] + fn compile_to_slr(&self, compact: bool) -> PyResult { + let program = self.build_ast(); + + // Semantic analysis + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // SLR codegen + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(codegen_error_to_py) + } else { + codegen.to_json(&slr_program).map_err(codegen_error_to_py) + } + } + + /// Compile to SLR-AST as Python dict. + fn compile_to_slr_dict(&self, py: Python<'_>) -> PyResult> { + let json_str = self.compile_to_slr(false)?; + let json_module = py.import("json")?; + let result = json_module.call_method1("loads", (json_str,))?; + Ok(result.into()) + } + + /// Compile to HUGR bytes. + /// + /// Returns: + /// bytes: HUGR in binary envelope format + fn compile_to_hugr(&self, py: Python<'_>) -> PyResult> { + let program = self.build_ast(); + + // Semantic analysis + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // HUGR codegen + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + let bytes = codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?; + + Ok(pyo3::types::PyBytes::new(py, &bytes).into()) + } + + /// Generate Zlup source code from the built AST. + /// + /// Returns: + /// str: Zlup source code + fn to_source(&self) -> String { + let program = self.build_ast(); + ::zlup::pretty::pretty_print(&program, &::zlup::pretty::PrettyOptions::default()) + } + + /// Save the program source code to a .zlp file. + /// + /// Args: + /// path: Path to write the .zlp file + /// + /// Raises: + /// `IOError`: If the file cannot be written + fn save(&self, path: &str) -> PyResult<()> { + let source = self.to_source(); + std::fs::write(path, source) + .map_err(|e| PyIOError::new_err(format!("Failed to write {path}: {e}"))) + } + + /// Compile via source code generation and parsing. + /// + /// This generates source code, parses it back, and compiles to SLR. + /// Useful for testing that the generated source is valid Zlup code. + /// + /// Args: + /// compact: Return compact JSON (default: False) + /// + /// Returns: + /// str: SLR-AST as JSON string + #[pyo3(signature = (compact = false))] + fn compile_via_source_to_slr(&self, compact: bool) -> PyResult { + let source = self.to_source(); + + // Parse the generated source + let program = ::zlup::parse(&source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // SLR codegen + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(codegen_error_to_py) + } else { + codegen.to_json(&slr_program).map_err(codegen_error_to_py) + } + } + + /// Compile via source code generation and parsing to HUGR. + /// + /// This generates source code, parses it back, and compiles to HUGR. + /// Useful for testing that the generated source is valid Zlup code. + /// + /// Returns: + /// bytes: HUGR in binary envelope format + fn compile_via_source_to_hugr(&self, py: Python<'_>) -> PyResult> { + let source = self.to_source(); + + // Parse the generated source + let program = ::zlup::parse(&source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // HUGR codegen + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + let bytes = codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?; + + Ok(pyo3::types::PyBytes::new(py, &bytes).into()) + } + + fn __repr__(&self) -> String { + format!( + "ZlupProgram(name={:?}, statements={}, strict={})", + self.name, + self.statements.len(), + self.strict + ) + } +} + +// Internal methods for ZlupProgram (not exposed to Python) +impl ZlupProgram { + /// Build the Zlup AST Program. + fn build_ast(&self) -> ::zlup::ast::Program { + // Create main function with all statements + let main_fn = ::zlup::ast::FnDecl { + name: self.name.clone(), + params: Vec::new(), + return_type: Some(::zlup::ast::TypeExpr::Unit), + body: ::zlup::ast::Block { + label: None, + attrs: Vec::new(), + statements: self.statements.clone(), + trailing_expr: None, + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + ::zlup::ast::Program { + name: self.name.clone(), + declarations: vec![::zlup::ast::TopLevelDecl::Fn(main_fn)], + location: None, + } + } +} + +// ============================================================================= +// ZluppyEngine +// ============================================================================= + +/// Engine for compiling and running Zluppy programs. +/// +/// Provides a fluent interface for compiling Zluppy source to HUGR +/// and running it through PECOS's simulator. +/// +/// Example: +/// ```python +/// result = zluppy.ZluppyEngine().source(''' +/// fn main() -> void { +/// var q = qalloc(2); +/// H(q[0]); +/// CX(q[0], q[1]); +/// } +/// ''').run(shots=100) +/// print(result.to_dict()) +/// ``` +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +struct ZluppyEngine { + strict: bool, + hugr_bytes: Option>, +} + +#[pymethods] +impl ZluppyEngine { + /// Create a new `ZluppyEngine`. + /// + /// Args: + /// strict: Enable strict mode (NASA Power of 10 checks). Default: False + #[new] + #[pyo3(signature = (strict = false))] + fn new(strict: bool) -> Self { + Self { + strict, + hugr_bytes: None, + } + } + + /// Compile Zluppy source code. + /// + /// Args: + /// code: Zluppy source code as a string + /// + /// Returns: + /// self: For method chaining + fn source(&mut self, code: &str) -> PyResult { + let program = ::zlup::parse(code).map_err(parse_error_to_py)?; + + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + self.hugr_bytes = Some(codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?); + + Ok(self.clone()) + } + + /// Compile a .zlp file. + /// + /// Args: + /// path: Path to a .zlp file + /// + /// Returns: + /// self: For method chaining + fn file(&mut self, path: &str) -> PyResult { + let source = read_file(path)?; + let filename = filename_from_path(path); + + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + self.hugr_bytes = Some(codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?); + + Ok(self.clone()) + } + + /// Return the compiled HUGR bytes. + fn to_hugr_bytes(&self, py: Python<'_>) -> PyResult> { + let hugr_bytes = self.hugr_bytes.as_ref().ok_or_else(|| { + PyValueError::new_err("No source compiled. Call .source() or .file() first.") + })?; + Ok(pyo3::types::PyBytes::new(py, hugr_bytes).into()) + } + + fn __repr__(&self) -> String { + let status = if self.hugr_bytes.is_some() { + "compiled" + } else { + "not compiled" + }; + format!("ZluppyEngine(strict={}, status={})", self.strict, status) + } +} + +// ============================================================================= +// Module Definition +// ============================================================================= + +/// Zluppy Python module (internal). +/// +/// A Zig/SLR/NASA Power of 10 reflection of Guppy's approach to quantum programming. +#[pymodule] +fn _zluppy(m: &Bound<'_, PyModule>) -> PyResult<()> { + // Add exception + m.add("ZluppyError", m.py().get_type::())?; + + // Add functions + m.add_function(wrap_pyfunction!(compile_to_slr, m)?)?; + m.add_function(wrap_pyfunction!(compile_to_slr_json, m)?)?; + m.add_function(wrap_pyfunction!(check, m)?)?; + m.add_function(wrap_pyfunction!(parse_debug, m)?)?; + m.add_function(wrap_pyfunction!(version, m)?)?; + + // File-based functions + m.add_function(wrap_pyfunction!(compile_file, m)?)?; + m.add_function(wrap_pyfunction!(compile_file_json, m)?)?; + m.add_function(wrap_pyfunction!(check_file, m)?)?; + + // HUGR compilation functions + m.add_function(wrap_pyfunction!(compile_to_hugr, m)?)?; + m.add_function(wrap_pyfunction!(compile_file_hugr, m)?)?; + + // Add classes + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + Ok(()) +} diff --git a/exp/zluppy/tests/test_zluppy.py b/exp/zluppy/tests/test_zluppy.py new file mode 100644 index 000000000..77f71ed93 --- /dev/null +++ b/exp/zluppy/tests/test_zluppy.py @@ -0,0 +1,981 @@ +"""Tests for the Zluppy Python bindings.""" + +import json + +import pytest + +import zluppy + +# ============================================================================= +# Version Tests +# ============================================================================= + + +def test_version(): + """Test that version returns a non-empty string.""" + v = zluppy.version() + assert isinstance(v, str) + assert len(v) > 0 + assert "." in v # Should be semantic version like "0.1.0" + + +# ============================================================================= +# compile_to_slr Tests +# ============================================================================= + + +def test_compile_to_slr_bell_state(): + """Test compiling a Bell state program to SLR-AST dict.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + result = zluppy.compile_to_slr(source) + + assert isinstance(result, dict) + assert result["type"] == "Program" + assert result["name"] == "main" + assert result["allocator"]["name"] == "q" + assert result["allocator"]["capacity"] == 2 + + body = result["body"] + assert len(body) == 2 + assert body[0]["gate"] == "H" + assert body[1]["gate"] == "CX" + + +def test_compile_to_slr_rotation_gate(): + """Test compiling a rotation gate program.""" + source = """fn main() -> void { + var q = qalloc(1); + rz(q[0], 1.57); + }""" + + result = zluppy.compile_to_slr(source) + + body = result["body"] + assert len(body) == 1 + assert body[0]["gate"] == "RZ" + assert len(body[0]["params"]) > 0 + + +def test_compile_to_slr_child_allocator(): + """Test compiling with child allocator.""" + source = """fn main() -> void { + var base = qalloc(4); + var q = base.child(2); + h(q[0]); + }""" + + result = zluppy.compile_to_slr(source) + + # Should have multiple allocator declarations + decls = result["declarations"] + assert len(decls) >= 2 + + +def test_compile_to_slr_strict_mode(): + """Test compiling with strict mode enabled.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + }""" + + # Strict mode should still compile valid code + result = zluppy.compile_to_slr(source, strict=True) + assert result["type"] == "Program" + + +def test_compile_to_slr_parse_error(): + """Test that parse errors raise ZluppyError.""" + source = "fn main() -> void { h(q[0] }" # Missing closing paren + + with pytest.raises(zluppy.ZluppyError) as exc_info: + zluppy.compile_to_slr(source) + + assert "parse error" in str(exc_info.value).lower() or "expected" in str(exc_info.value).lower() + + +def test_compile_to_slr_semantic_error(): + """Test that semantic errors raise ZluppyError.""" + source = """fn main() -> void { + h(undefined_var[0]); + }""" + + with pytest.raises(zluppy.ZluppyError) as exc_info: + zluppy.compile_to_slr(source) + + assert "semantic" in str(exc_info.value).lower() or "undefined" in str(exc_info.value).lower() + + +# ============================================================================= +# compile_to_slr_json Tests +# ============================================================================= + + +def test_compile_to_slr_json_pretty(): + """Test compiling to pretty-printed JSON string.""" + source = """fn main() -> void { + var q = qalloc(1); + h(q[0]); + }""" + + result = zluppy.compile_to_slr_json(source) + + assert isinstance(result, str) + # Pretty-printed JSON has newlines + assert "\n" in result + + # Should be valid JSON + parsed = json.loads(result) + assert parsed["type"] == "Program" + + +def test_compile_to_slr_json_compact(): + """Test compiling to compact JSON string.""" + source = """fn main() -> void { + var q = qalloc(1); + h(q[0]); + }""" + + result = zluppy.compile_to_slr_json(source, compact=True) + + assert isinstance(result, str) + # Compact JSON should not have indented newlines + assert "\n " not in result + + # Should be valid JSON + parsed = json.loads(result) + assert parsed["type"] == "Program" + + +def test_compile_to_slr_json_strict(): + """Test compiling to JSON with strict mode.""" + source = """fn main() -> void { + var q = qalloc(1); + x(q[0]); + }""" + + result = zluppy.compile_to_slr_json(source, strict=True) + parsed = json.loads(result) + assert parsed["type"] == "Program" + + +# ============================================================================= +# check Tests +# ============================================================================= + + +def test_check_valid_program(): + """Test checking a valid program.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + # Should not raise + zluppy.check(source) + + +def test_check_strict_mode(): + """Test checking in strict mode.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + }""" + + # Should not raise for valid code + zluppy.check(source, strict=True) + + +def test_check_parse_error(): + """Test that check raises on parse error.""" + source = "fn main( void { }" # Missing closing paren + + with pytest.raises(zluppy.ZluppyError): + zluppy.check(source) + + +def test_check_semantic_error(): + """Test that check raises on semantic error.""" + source = """fn main() -> void { + UnknownGate(q[0]); + }""" + + with pytest.raises(zluppy.ZluppyError): + zluppy.check(source) + + +# ============================================================================= +# parse_debug Tests +# ============================================================================= + + +def test_parse_debug(): + """Test getting debug AST string.""" + source = "fn main() -> void { var q = qalloc(1); }" + + result = zluppy.parse_debug(source) + + assert isinstance(result, str) + assert "Program" in result + assert "FnDecl" in result + + +def test_parse_debug_error(): + """Test that parse_debug raises on parse error.""" + with pytest.raises(zluppy.ZluppyError): + zluppy.parse_debug("fn main( void { }") + + +# ============================================================================= +# SlrProgram Builder Tests +# ============================================================================= + + +def test_slr_program_create(): + """Test creating an SlrProgram.""" + prog = zluppy.SlrProgram("test") + + assert repr(prog) == 'SlrProgram(name="test")' + + +def test_slr_program_add_allocator(): + """Test adding an allocator to SlrProgram.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + + result = prog.to_dict() + + assert result["allocator"]["name"] == "q" + assert result["allocator"]["capacity"] == 2 + + +def test_slr_program_add_gate(): + """Test adding gates to SlrProgram.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + prog.add_gate("H", [("q", 0)]) + prog.add_gate("CX", [("q", 0), ("q", 1)]) + + result = prog.to_dict() + + body = result["body"] + assert len(body) == 2 + assert body[0]["gate"] == "H" + assert body[1]["gate"] == "CX" + + +def test_slr_program_add_rotation_gate(): + """Test adding rotation gate with parameters.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate("RZ", [("q", 0)], [3.14159]) + + result = prog.to_dict() + + body = result["body"] + assert len(body) == 1 + assert body[0]["gate"] == "RZ" + assert len(body[0]["params"]) == 1 + + +def test_slr_program_add_prepare(): + """Test adding prepare operation.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + prog.add_prepare("q") # Prepare all + + result = prog.to_dict() + + body = result["body"] + assert len(body) == 1 + assert body[0]["type"] == "PrepareOp" + + +def test_slr_program_add_prepare_slots(): + """Test adding prepare operation with specific slots.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 3) + prog.add_prepare("q", [0, 1]) # Prepare specific slots + + result = prog.to_dict() + + body = result["body"] + assert len(body) == 1 + assert body[0]["type"] == "PrepareOp" + + +def test_slr_program_to_json(): + """Test converting SlrProgram to JSON.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate("H", [("q", 0)]) + + result = prog.to_json() + + assert isinstance(result, str) + parsed = json.loads(result) + assert parsed["name"] == "test" + + +def test_slr_program_to_json_compact(): + """Test converting SlrProgram to compact JSON.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate("X", [("q", 0)]) + + result = prog.to_json(compact=True) + + assert isinstance(result, str) + assert "\n " not in result # No pretty-printing + + +def test_slr_program_unknown_gate(): + """Test that unknown gates raise ValueError.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + + with pytest.raises(ValueError, match="Unknown gate") as exc_info: + prog.add_gate("UnknownGate", [("q", 0)]) + + assert "Unknown gate" in str(exc_info.value) + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +def test_roundtrip_compile_and_build(): + """Test that compiled and built programs produce similar structure.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + # Compile from source + compiled = zluppy.compile_to_slr(source) + + # Build equivalent program + prog = zluppy.SlrProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("H", [("q", 0)]) + prog.add_gate("CX", [("q", 0), ("q", 1)]) + built = prog.to_dict() + + # Both should have same structure + assert compiled["type"] == built["type"] + assert compiled["name"] == built["name"] + assert len(compiled["body"]) == len(built["body"]) + + +def test_all_single_qubit_gates(): + """Test all supported single-qubit gates.""" + gates = ["H", "X", "Y", "Z", "SX", "SY", "SZ", "SXdg", "SYdg", "SZdg", "T", "Tdg", "F", "Fdg", "F4", "F4dg"] + + for gate in gates: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate(gate, [("q", 0)]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == gate + + +def test_all_two_qubit_gates(): + """Test all supported two-qubit gates.""" + gates = ["CX", "CY", "CZ", "CH", "SWAP", "iSWAP", "SXX", "SYY", "SZZ", "SXXdg", "SYYdg", "SZZdg"] + + for gate in gates: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + prog.add_gate(gate, [("q", 0), ("q", 1)]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == gate + + +def test_three_qubit_gates(): + """Test three-qubit gates (CCX/Toffoli).""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 3) + prog.add_gate("CCX", [("q", 0), ("q", 1), ("q", 2)]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == "CCX" + + +def test_all_rotation_gates(): + """Test all supported rotation gates.""" + # Single-qubit rotations + for gate in ["RX", "RY", "RZ"]: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate(gate, [("q", 0)], [1.57]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == gate + assert len(result["body"][0]["params"]) == 1 + + +def test_two_qubit_rotation_gates(): + """Test two-qubit rotation gates.""" + # CRZ and RZZ need 2 qubits + 1 angle + for gate in ["CRZ", "RZZ"]: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + prog.add_gate(gate, [("q", 0), ("q", 1)], [1.57]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == gate + assert len(result["body"][0]["params"]) == 1 + + +def test_lowercase_gate_aliases(): + """Test lowercase gate aliases in builder API.""" + # Lowercase names should map to uppercase SLR-AST output + for gate, expected in [("h", "H"), ("x", "X"), ("cx", "CX"), ("rx", "RX")]: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + if gate in ["cx"]: + prog.add_gate(gate, [("q", 0), ("q", 1)]) + elif gate in ["rx"]: + prog.add_gate(gate, [("q", 0)], [0.5]) + else: + prog.add_gate(gate, [("q", 0)]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == expected + + +# ============================================================================= +# ZluppyEngine Tests +# ============================================================================= + + +def test_zluppy_engine_source(): + """Test ZluppyEngine with source code.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + + assert isinstance(hugr_bytes, bytes) + assert len(hugr_bytes) > 0 + + +def test_zluppy_engine_file(tmp_path): + """Test ZluppyEngine with file input.""" + # Create a temporary .zlp file + zlp_file = tmp_path / "test.zlp" + zlp_file.write_text("""fn main() -> void { + var q = qalloc(1); + h(q[0]); + }""") + + engine = zluppy.ZluppyEngine().file(str(zlp_file)) + hugr_bytes = engine.to_hugr_bytes() + + assert isinstance(hugr_bytes, bytes) + assert len(hugr_bytes) > 0 + + +def test_zluppy_engine_run(): + """Test ZluppyEngine.run() executes through simulator.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + result = zluppy.ZluppyEngine().source(source).run(shots=10) + + # Check we got results + assert result is not None + result_dict = result.to_dict() + assert "measurements" in result_dict + assert len(result_dict["measurements"]) == 10 + + # Verify Bell state correlations (q0 == q1 for each shot) + for shot in result_dict["measurements"]: + assert shot[0] == shot[1], f"Bell state violation: {shot}" + + +def test_zluppy_engine_run_single_qubit(): + """Test ZluppyEngine with single qubit circuit.""" + source = """fn main() -> void { + var q = qalloc(1); + x(q[0]); + }""" + + result = zluppy.ZluppyEngine().source(source).run(shots=5) + result_dict = result.to_dict() + + # X gate should always give |1⟩ + for shot in result_dict["measurements"]: + assert shot == [1], f"Expected [1], got {shot}" + + +def test_zluppy_engine_no_source_error(): + """Test that to_hugr_bytes raises without source.""" + engine = zluppy.ZluppyEngine() + + with pytest.raises(ValueError, match="No source compiled") as exc_info: + engine.to_hugr_bytes() + + assert "No source compiled" in str(exc_info.value) + + +def test_zluppy_engine_parse_error(): + """Test that ZluppyEngine raises on parse error.""" + source = "fn main() -> void { h(q[0] }" # Missing closing paren + + with pytest.raises(zluppy.ZluppyError): + zluppy.ZluppyEngine().source(source) + + +def test_zluppy_engine_semantic_error(): + """Test that ZluppyEngine raises on semantic error.""" + source = """fn main() -> void { + h(undefined_var[0]); + }""" + + with pytest.raises(zluppy.ZluppyError): + zluppy.ZluppyEngine().source(source) + + +def test_zluppy_engine_strict_mode(): + """Test ZluppyEngine with strict mode.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + }""" + + # Strict mode should work for valid code + engine = zluppy.ZluppyEngine(strict=True).source(source) + hugr_bytes = engine.to_hugr_bytes() + + assert len(hugr_bytes) > 0 + + +def test_zluppy_engine_chaining(): + """Test that ZluppyEngine methods return self for chaining.""" + source = """fn main() -> void { + var q = qalloc(1); + h(q[0]); + }""" + + # All methods should be chainable + result = zluppy.ZluppyEngine().source(source).run(shots=1) + assert result is not None + + +def test_zluppy_engine_repr(): + """Test ZluppyEngine repr.""" + engine = zluppy.ZluppyEngine() + assert "not compiled" in repr(engine) + + engine.source("fn main() -> void { var q = qalloc(1); }") + assert "compiled" in repr(engine) + + +def test_zluppy_engine_file_not_found(): + """Test ZluppyEngine raises on missing file.""" + with pytest.raises(OSError, match="Failed to read"): + zluppy.ZluppyEngine().file("/nonexistent/path/to/file.zlp") + + +# ============================================================================= +# End-to-End Gate Tests (compile through HUGR) +# ============================================================================= + + +def test_engine_ch_gate(): + """Test CH (controlled Hadamard) gate compiles to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + ch(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_ising_gates(): + """Test Ising gates (SXX, SYY, SZZ) compile to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + sxx(q[0], q[1]); + syy(q[0], q[1]); + szz(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_ising_dagger_gates(): + """Test Ising dagger gates compile to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + sxxdg(q[0], q[1]); + syydg(q[0], q[1]); + szzdg(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_rzz_gate(): + """Test RZZ (ZZ rotation) gate compiles to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + rzz(q[0], q[1], 1.57); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_f_gates(): + """Test F gates (Clifford face rotation) compile to HUGR.""" + source = """fn main() -> void { + var q = qalloc(1); + f(q[0]); + fdg(q[0]); + f4(q[0]); + f4dg(q[0]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_ccx_gate(): + """Test CCX (Toffoli) gate compiles to HUGR.""" + source = """fn main() -> void { + var q = qalloc(3); + ccx(q[0], q[1], q[2]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_swap_gates(): + """Test SWAP and iSWAP gates compile to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + swap(q[0], q[1]); + iswap(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_compile_all_new_gates(): + """Test compiling a program with all new gates to SLR.""" + source = """fn main() -> void { + var q = qalloc(3); + // F gates + f(q[0]); + fdg(q[0]); + f4(q[0]); + f4dg(q[0]); + // Two-qubit controlled + ch(q[0], q[1]); + // Ising gates + sxx(q[0], q[1]); + syy(q[0], q[1]); + szz(q[0], q[1]); + sxxdg(q[0], q[1]); + syydg(q[0], q[1]); + szzdg(q[0], q[1]); + // Swap gates + swap(q[0], q[1]); + iswap(q[0], q[1]); + // Rotation + rzz(q[0], q[1], 0.5); + crz(q[0], q[1], 0.5); + // Three-qubit + ccx(q[0], q[1], q[2]); + }""" + + result = zluppy.compile_to_slr(source) + assert result["type"] == "Program" + # Should have 16 operations in body + assert len(result["body"]) == 16 + + +# ============================================================================= +# ZlupProgram Builder Tests +# ============================================================================= + + +def test_zlup_program_create(): + """Test creating a ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + assert repr(prog) == 'ZlupProgram(name="main", statements=0, strict=false)' + + +def test_zlup_program_add_allocator(): + """Test adding an allocator to ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + + source = prog.to_source() + assert "var q = qalloc(2);" in source + + +def test_zlup_program_add_gate(): + """Test adding a gate to ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + source = prog.to_source() + assert "h(q[0]);" in source + assert "cx(q[0], q[1]);" in source + + +def test_zlup_program_add_rotation_gate(): + """Test adding a rotation gate with parameters.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 1) + prog.add_gate("rz", [("q", 0)], params=[1.57]) + + source = prog.to_source() + assert "rz(q[0], 1.57);" in source + + +def test_zlup_program_compile_to_slr(): + """Test compiling ZlupProgram to SLR-AST.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + slr_json = prog.compile_to_slr() + result = json.loads(slr_json) + + assert result["type"] == "Program" + assert result["name"] == "main" + assert len(result["body"]) == 2 + assert result["body"][0]["gate"] == "H" + assert result["body"][1]["gate"] == "CX" + + +def test_zlup_program_compile_to_slr_dict(): + """Test compiling ZlupProgram to SLR-AST dict.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 1) + prog.add_gate("x", [("q", 0)]) + + result = prog.compile_to_slr_dict() + + assert isinstance(result, dict) + assert result["body"][0]["gate"] == "X" + + +def test_zlup_program_compile_to_hugr(): + """Test compiling ZlupProgram to HUGR.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + hugr_bytes = prog.compile_to_hugr() + + assert isinstance(hugr_bytes, bytes) + assert len(hugr_bytes) > 0 + + +def test_zlup_program_method_chaining(): + """Test that ZlupProgram methods support chaining.""" + prog = ( + zluppy.ZlupProgram("main").add_allocator("q", 2).add_gate("h", [("q", 0)]).add_gate("cx", [("q", 0), ("q", 1)]) + ) + + source = prog.to_source() + assert "var q = qalloc(2);" in source + assert "h(q[0]);" in source + assert "cx(q[0], q[1]);" in source + + +def test_zlup_program_all_single_qubit_gates(): + """Test all single-qubit gates in ZlupProgram.""" + gates = ["h", "x", "y", "z", "s", "sdg", "t", "tdg", "sx", "sxdg", "sy", "sydg", "sz", "szdg"] + + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 1) + for gate in gates: + prog.add_gate(gate, [("q", 0)]) + + result = json.loads(prog.compile_to_slr()) + assert len(result["body"]) == len(gates) + + +def test_zlup_program_all_two_qubit_gates(): + """Test all two-qubit gates in ZlupProgram.""" + gates = ["cx", "cy", "cz", "ch", "sxx", "syy", "szz", "sxxdg", "syydg", "szzdg"] + + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + for gate in gates: + prog.add_gate(gate, [("q", 0), ("q", 1)]) + + result = json.loads(prog.compile_to_slr()) + assert len(result["body"]) == len(gates) + + +def test_zlup_program_rotation_gates(): + """Test rotation gates in ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("rx", [("q", 0)], params=[0.5]) + prog.add_gate("ry", [("q", 0)], params=[0.5]) + prog.add_gate("rz", [("q", 0)], params=[0.5]) + prog.add_gate("rzz", [("q", 0), ("q", 1)], params=[0.5]) + + result = json.loads(prog.compile_to_slr()) + assert len(result["body"]) == 4 + assert result["body"][0]["gate"] == "RX" + assert result["body"][3]["gate"] == "RZZ" + + +def test_zlup_program_prepare(): + """Test prepare operation in ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_prepare("q") # Prepare all + prog.add_gate("h", [("q", 0)]) + + source = prog.to_source() + assert "q.prepare_all();" in source + + +def test_zlup_program_measure(): + """Test measure operation in ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_measure([("q", 0), ("q", 1)]) + + source = prog.to_source() + assert "measure(q[0], q[1]);" in source + + +def test_zlup_program_unknown_gate_error(): + """Test that unknown gates raise an error.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 1) + + with pytest.raises(ValueError, match="Unknown gate") as exc_info: + prog.add_gate("unknown_gate", [("q", 0)]) + + assert "Unknown gate" in str(exc_info.value) + + +def test_zlup_program_strict_mode(): + """Test ZlupProgram with strict mode.""" + prog = zluppy.ZlupProgram("main", strict=True) + assert "strict=true" in repr(prog) + + +def test_zlup_program_save(tmp_path): + """Test saving ZlupProgram to a .zlp file.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + path = tmp_path / "bell.zlp" + prog.save(str(path)) + + # Verify file contents + content = path.read_text() + assert "fn main() -> void {" in content + assert "var q = qalloc(2);" in content + assert "h(q[0]);" in content + assert "cx(q[0], q[1]);" in content + + # Verify the saved file can be compiled + result = zluppy.compile_file(str(path)) + assert result["type"] == "Program" + + +def test_zlup_program_compile_via_source_to_slr(): + """Test compile_via_source_to_slr round-trips correctly.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + # Compile via AST + slr_ast = prog.compile_to_slr() + + # Compile via source (generate -> parse -> compile) + slr_source = prog.compile_via_source_to_slr() + + # Both should produce equivalent results + ast_data = json.loads(slr_ast) + source_data = json.loads(slr_source) + + assert ast_data["name"] == source_data["name"] + assert len(ast_data["body"]) == len(source_data["body"]) + assert [g["gate"] for g in ast_data["body"]] == [g["gate"] for g in source_data["body"]] + + +def test_zlup_program_compile_via_source_to_hugr(): + """Test compile_via_source_to_hugr works.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + hugr_bytes = prog.compile_via_source_to_hugr() + + assert isinstance(hugr_bytes, bytes) + assert len(hugr_bytes) > 0 + + +def test_zlup_program_roundtrip_all_gates(): + """Test that all gates round-trip through source generation.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 3) + + # Add various gates + single_qubit = ["h", "x", "y", "z", "s", "sdg", "t", "tdg", "sx", "sxdg"] + two_qubit = ["cx", "cy", "cz", "ch", "sxx", "syy", "szz"] + + for gate in single_qubit: + prog.add_gate(gate, [("q", 0)]) + for gate in two_qubit: + prog.add_gate(gate, [("q", 0), ("q", 1)]) + prog.add_gate("rz", [("q", 0)], params=[1.57]) + + # Should compile successfully via source + slr = prog.compile_via_source_to_slr() + data = json.loads(slr) + + expected_count = len(single_qubit) + len(two_qubit) + 1 # +1 for rz + assert len(data["body"]) == expected_count diff --git a/exp/zluppy/uv.lock b/exp/zluppy/uv.lock new file mode 100644 index 000000000..3cbdc80f3 --- /dev/null +++ b/exp/zluppy/uv.lock @@ -0,0 +1,1447 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + +[[package]] +name = "guppylang" +version = "0.21.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "guppylang-internals" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pytket" }, + { name = "selene-hugr-qis-compiler" }, + { name = "selene-sim" }, + { name = "tqdm" }, + { name = "types-tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/79/e148d43eebdbd0e670cfe983d2806c1d8199d4b69e45680a352d2a6c6265/guppylang-0.21.16.tar.gz", hash = "sha256:37ecb3267734540104ada3be1e12726f631edfba2b013efa8f8064cc324d7fda", size = 70378, upload-time = "2026-06-04T14:57:10.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/6a/7120b688f8543ef39ac1d42d325233aacde677a562fe72e3e51f10e433ee/guppylang-0.21.16-py3-none-any.whl", hash = "sha256:22ada5f013d96574102b89d88c290449b23f9972a8f0e88e92227308b8aa275c", size = 67031, upload-time = "2026-06-04T14:57:09.183Z" }, +] + +[[package]] +name = "guppylang-internals" +version = "0.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hugr" }, + { name = "pytket" }, + { name = "tket", extra = ["pytket"] }, + { name = "tket-exts" }, + { name = "typing-extensions" }, + { name = "wasmtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/67/c2ea5658a28d1ea814a64e91cf0433197dd0e99a3eab1dc3538e14499634/guppylang_internals-0.36.1.tar.gz", hash = "sha256:3ee67ffdd7e97cf9273f9a65697c48fb192bb98f110c960931d6ea1f2b7f0934", size = 214605, upload-time = "2026-06-04T14:53:42.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/ac/f66326c93cc7e0c74ec8a195d23c78690cb470c77519e77a4d4876f5d355/guppylang_internals-0.36.1-py3-none-any.whl", hash = "sha256:d70dc644bdfaff46b5b94b308aa90e4aa667c60b85f72a82fbc1ea729d2dfe51", size = 267518, upload-time = "2026-06-04T14:53:40.954Z" }, +] + +[[package]] +name = "hugr" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphviz" }, + { name = "pydantic" }, + { name = "pydantic-extra-types" }, + { name = "semver" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/29/129d978423d9e74ee42a25a0099bea068b48e66c2d1eb4f7f5faa128fb67/hugr-0.16.0.tar.gz", hash = "sha256:de4827cfe27e9d6ee2a764382825ae7ef8bd3282947f3c59114cc0de5a198124", size = 986110, upload-time = "2026-04-01T13:04:20.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/54/c8593f1e41aedff29cd6fab66a60ffaa68380a8b8fc31841b53d1919f05c/hugr-0.16.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:270bb717d28310e297995f7e84335336d4a896b048468ca6d5a56e672e9c83f8", size = 3399386, upload-time = "2026-04-01T13:04:17.615Z" }, + { url = "https://files.pythonhosted.org/packages/9b/0d/e9d90f6895ab0a8e144f5030f2384f85a98af98dd34f616e157863b09ffc/hugr-0.16.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:759a52fd1a0e2c2a1defbddc88505294cd1ec945d422cb170e7c016c946fbcce", size = 3041962, upload-time = "2026-04-01T13:04:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/cd/41/e3ebf1fed1da84081f4e11bba9ae82a5270856288b5f38d5cbe1578bf226/hugr-0.16.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d484a7c872fb81bf359e8678def627d2f0fd64516b0e131a789d2198410117e2", size = 3362746, upload-time = "2026-04-01T13:03:43.218Z" }, + { url = "https://files.pythonhosted.org/packages/08/34/05d82ac6f872aa02cbd920927d7c1ce11163e8321fd9e053cd601bfe0d74/hugr-0.16.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e9693f512909e76abf69072b5504615cd022034ff828f9b7f30f95c51e05af74", size = 3340868, upload-time = "2026-04-01T13:03:45.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/2c/7401bb1edf930576f51e5678ae9a74d11f10c0ef83f42adf06397de2f3e1/hugr-0.16.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d66d2c07adc54065df2a244bcbc67c41c5adb9e682b8d5b3e9946bebb54d791d", size = 3776711, upload-time = "2026-04-01T13:03:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/748222a7c4c84353f8b5741240ca3c51c9e29d706884296bf44e403010cb/hugr-0.16.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e653ad5a93eaf9a9197e8d5126329ac95c4def3d878d10f5a9a9c18cebe788dd", size = 3855897, upload-time = "2026-04-01T13:03:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/eee150b3c7454ef022f1de4003ed7084af4ddebf3efadc5f6bb060306f73/hugr-0.16.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:02be6d8080372cae20c9683438e297b5f38ef1a3ab8488b34c614e575e00ee65", size = 3608483, upload-time = "2026-04-01T17:50:43.25Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/6da7d6d35db29f7782b692f9df79f5be6882d62da4067f408cdcae1dac5a/hugr-0.16.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44b750e5f725a827ac12524bd35199f956267c7ff383039cfcdb419e1ee22e31", size = 3656539, upload-time = "2026-04-01T17:50:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2e/d81e42a57de81020cb14089fe45f76337f59bc9aca9f1483e4552f3c2bee/hugr-0.16.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e8a4e6b0f17a5f56c387539cf02fb6eacbc66cbd86b5ce07b09e6a860b254ce5", size = 3569466, upload-time = "2026-04-01T13:04:00.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/0f/23845b23a6aeac0d02a89e06004dbcda286d0500314f68c73c34b07b5c78/hugr-0.16.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a087257e1d60ce6fc266a3f6d734935554a78b3dddd429698418b4f5eee49e09", size = 3617572, upload-time = "2026-04-01T13:04:03.33Z" }, + { url = "https://files.pythonhosted.org/packages/5b/43/84f8d9399fa77d04f6b4d1ef856b98b8520110f844bf1b65d6c98ac4edc1/hugr-0.16.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:8f9eaabf8439cbdf7e0d873fc10506d7f8db9673966d3d934fb8a1ec86bbc0a9", size = 3705137, upload-time = "2026-04-01T13:04:06.031Z" }, + { url = "https://files.pythonhosted.org/packages/da/9d/b3d75e12ad0265ea9cbfabb49cc9072e6109596d8777031e9a3d4d016f6d/hugr-0.16.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9781813e4222fe1498bd8adb3b31e8ce4e92e925bfd097ec24d8354b87f51d2c", size = 3881014, upload-time = "2026-04-01T13:04:08.983Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ca/d71f8374f62831472269927f2adf4b0d14e35d25769940dc05a23e8930e0/hugr-0.16.0-cp310-abi3-win32.whl", hash = "sha256:9649bf4552bbe9cf93eec21a344d50c7a4a8429aa15928e4647778a9bbf92488", size = 2954806, upload-time = "2026-04-01T13:04:13.447Z" }, + { url = "https://files.pythonhosted.org/packages/72/5a/3dbf899aacdf2978a57ccd2a5ee0832f92608b67fecf43440a1a125f0a48/hugr-0.16.0-cp310-abi3-win_amd64.whl", hash = "sha256:c75101ca5b9c846f20502d4cfa88286a2784bd7c0d653d8025040393e3a9e568", size = 3236422, upload-time = "2026-04-01T13:04:11.801Z" }, + { url = "https://files.pythonhosted.org/packages/31/23/98154df19a32bc8807d66d22ffe321f02c23fd814b225f0a5bc53c0671ac/hugr-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:c5fe2702d5e80e831c39c701b5eae3f277b089b85a92cdd55f53fba61851ecc1", size = 3399228, upload-time = "2026-04-01T13:04:18.798Z" }, + { url = "https://files.pythonhosted.org/packages/a0/73/7030d9185cf52a216f50fd2df829c0cf0ddc78205e2ca09a192f95c9b952/hugr-0.16.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:007694eb785cf0ce9eaf32dbcc94375ae8ec5c3da1938141bfa97f28e9afe0d4", size = 3042296, upload-time = "2026-04-01T13:04:16.285Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5c/ecc2637f528b14d0cf6e400978526e89c5dd51c1aa3b25da4f12b5380404/hugr-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a97b7cebec63bcc83e10747a94aa4a42a7f1b7127d56a12f8c9151014a8265c", size = 3363178, upload-time = "2026-04-01T13:03:44.559Z" }, + { url = "https://files.pythonhosted.org/packages/54/a8/1a76e227117acc914d6c53050b8c29332d2dd5f464f2bea9606550ee163c/hugr-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebaded1dbc81282fd519f29be7eab2e884f25dc60fb866b88bc4316e0998ea6a", size = 3342289, upload-time = "2026-04-01T13:03:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/de3f56e3edef9f2722071d9d48167be764d467733b4a7639d74171df4e6c/hugr-0.16.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6165273000247817467f0c97582555b96cabf2ddf667c10d9de11272e94531ae", size = 3603558, upload-time = "2026-04-01T13:03:54.839Z" }, + { url = "https://files.pythonhosted.org/packages/b4/16/faa2c8910b12d1fa71697fd5f342e5782a2ce55cbba17a9f62d0d617af17/hugr-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5be77f7840f1dde1e6e6f6d472c40f2c2726f4b20430482e76a943dc4270111e", size = 3773176, upload-time = "2026-04-01T13:03:50.302Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/f62ef65b21ff37bcd7eed15e1cdd777e940a1f2389eab670d1adac0bf062/hugr-0.16.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90c4dd63f0fa07b0fcbd5cf61b78567aa23cb5003092b571a84aae0c27fa5737", size = 3855081, upload-time = "2026-04-01T13:03:52.746Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/db53d482143e8388059eba9c9f14a128b3444b37f6fb1435c6eff0447047/hugr-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdce09aa90f305caccba667251d7fc6fcd2f469a840bd9ea810640f9cc3c3b21", size = 3641209, upload-time = "2026-04-01T13:03:57.805Z" }, + { url = "https://files.pythonhosted.org/packages/d4/72/583c458708bf156c8f69210b4bb04be19a217818e710bfec8c8933d06994/hugr-0.16.0-cp313-cp313t-manylinux_2_28_i686.whl", hash = "sha256:60fe672f0349fbc4be031e36b986e174bc68d9185ad7eeb5c3d67b71f98937be", size = 3606359, upload-time = "2026-04-01T17:50:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e4/72282c75cb607222e6aedc2fbb42cd61a8fa180d5d5a820e67682258241a/hugr-0.16.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a4bdc164c91111db8249d51126867be7ea2e774398cade7fe5432a48b5997279", size = 3650296, upload-time = "2026-04-01T17:50:57.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/12db6e42e24f0af8aad11b6aa87fdd731a0dfa53617fc879cb3bc7604f4d/hugr-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0faea63ade58f6fb051606a778fd06c397ae1cf18a1a7218123853e9ca8a6fe", size = 3569708, upload-time = "2026-04-01T13:04:02.12Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/106a5b8e618c1f4f9f501d55aae6783fb2afb77419f41a8b8b293ecfbe78/hugr-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:dceb496d1e120871c8df7d4e91f30d7ac91d19519690fd45f08c5f57e639e531", size = 3619769, upload-time = "2026-04-01T13:04:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/a608e321135e4ac0b84dd593497ee9fcd1098645884263296b42a80f695d/hugr-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c210a2bc18e65638623bb14b7c308957b7d0b9cdc8b2c8b244bc697a76966729", size = 3702898, upload-time = "2026-04-01T13:04:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/58/15/552629b919fc5d42b23f3c886bd66f33e401657346033860e5f847f5944f/hugr-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7b01e7f99b019cd77013bf4e55e0115862adf42d327c354297667ffd8023d209", size = 3873617, upload-time = "2026-04-01T13:04:10.568Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "lief" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/0e/f5fe72b1e729416fb036241de00a5d67324d122b5511207d4f133865459e/lief-0.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:68386290df636c37ac554a48a78a0ca04fc458056ab9751089f6cba7bf35b864", size = 2988239, upload-time = "2026-01-03T16:59:53.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/f3/5508e9251f0892433f374379753ac3a52bb90f46c8223a3ee854b4169376/lief-0.17.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:056e7656293d96c86944fb4cf1075e016d036d329cac4a22d81ebfaacfd6bd7c", size = 3097674, upload-time = "2026-01-03T16:59:55.511Z" }, + { url = "https://files.pythonhosted.org/packages/63/02/815728a9dd62109415af37fb0bdad933bcde401a1bb029380f2af57a37b9/lief-0.17.2-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a4a494415e2f4c3266402d1c3708b6c81f7cfd0fbb82d6f19484cd25c331e449", size = 3668402, upload-time = "2026-01-03T16:59:57.487Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1c/217597faa7227e51d0934ddc741bfe7c6f75c0109ce51006899c4e86ea5a/lief-0.17.2-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:be6fdb3d8e987245f7d4c70a5c1c79dc3a39bc5db813345d5711cfdcb1f47a19", size = 3495254, upload-time = "2026-01-03T16:59:59.031Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/96a752c0b224efd3ee5e662f082f7a23c7ef362a86d5e0c8c0ab782a33c2/lief-0.17.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8b32642fa80776ef57249b5d1f2e2b2fb8df21600d4d73668ffa2ce65c876a8e", size = 3395210, upload-time = "2026-01-03T17:00:01.08Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5b/b736e470df858a33c8887c84248638047384f349d16bd81a602dd7259100/lief-0.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c54fd4bc37f71eec24357758b81e231e56b9cd2652225e558cb0f90784bff0cd", size = 3589523, upload-time = "2026-01-03T17:00:02.721Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/455ce2b3047b919d7f0e132e0783084634b185a271ff31f544bf15ee8181/lief-0.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aab1e95e14d65cda4b6b6eede12d8ebc98d5e29a75ae57d738fa933b2a2dcf5c", size = 3951690, upload-time = "2026-01-03T17:00:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/60/fa/b60840bac4214efe54cfc607cc809118061be1bd4b69c03d22561d76201c/lief-0.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8dd3c8197882d5832ed0956b27c5a80754fe3593f0728ed16e2cf478e6ae920e", size = 3699112, upload-time = "2026-01-03T17:00:05.774Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ea/3cdc3a51932c36912028bdc89659e35d88937ba110fc364dd19fbc9d55fa/lief-0.17.2-cp310-cp310-win32.whl", hash = "sha256:630a3eab90ddc1a390e19245f4612079133724e94ec953f35031424565c2a4f4", size = 3450698, upload-time = "2026-01-03T17:00:07.418Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/f488a8bfd3b265b688eabfdfb242049c522f687f40b6eff57b00e5577051/lief-0.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:54371d658de9dc5e227bcb651ddd9647824fbc167679e7b0d2d1f8856c04a061", size = 3628128, upload-time = "2026-01-03T17:00:09.103Z" }, + { url = "https://files.pythonhosted.org/packages/e8/49/0a9167a4e252c97a7ee1d24841f5c98ccd36d03c5532601ab3151e7a53a1/lief-0.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b3d594ddd70842c88488760a5bfe902e067585b1da9f9318bf561bf509399e7", size = 2995387, upload-time = "2026-01-03T17:00:11.125Z" }, + { url = "https://files.pythonhosted.org/packages/cd/dd/0b17eb3b194ac136e156fd81fa3a04a7d09dd713c1b0d0b4e79303111482/lief-0.17.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4e8a105005283d16ce0cdfb92a5ad4b4afe836016006bb0b5e54c0d582a5a347", size = 3098994, upload-time = "2026-01-03T17:00:12.571Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dc/d8d39c663ac160332de975d22bb84889c3d2de193a24ed05a41d702893d5/lief-0.17.2-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:f8cb266d2b13f11f298c88640be3cd2a438b8fc84afe282261b1b10474fabe7e", size = 3665654, upload-time = "2026-01-03T17:00:14.533Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bf/406671f1f6669186c01c84321f9e57a4cbecab2d75f3724c5253f529caa6/lief-0.17.2-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:75306fe656a536b2f658759b20ca54e02e8d7862e11281f1822ee9e5f3562986", size = 3495740, upload-time = "2026-01-03T17:00:16.148Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/1a0c225f23055e44379005770ea712e9b09c65ec961bc943a7a1549460d2/lief-0.17.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:29c58ecacd54d6b12d55366022a4395c82956ae39711a2c4dbf442d832ec6dee", size = 3395339, upload-time = "2026-01-03T17:00:18.001Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4b/92de92d60d5463b90deaf9d3371c2310ca1d4cf2e469a42a7388160e34e8/lief-0.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:be37addb662e5899b295adbac1f00ed1241dca5883554f39152d80c658bf31ba", size = 3588452, upload-time = "2026-01-03T17:00:19.816Z" }, + { url = "https://files.pythonhosted.org/packages/37/1c/40fd5661654ce2f20aeead4480328a161051281c6f9f868fd60ed43d25d7/lief-0.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d985cd7b4637dc7d47f22bdc17053d1cd9094c7643cd02281b76f42b1a9d7d71", size = 3951862, upload-time = "2026-01-03T17:00:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/fd/92/bb2e2a6038303e181287b8d7236db000db76eaccd34fb6c7c88f1b863635/lief-0.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:38a4b8570be6f51d845c85e4728fb88b9f956bb0ebca193201cd186ac8fb7e9b", size = 3699309, upload-time = "2026-01-03T17:00:24.081Z" }, + { url = "https://files.pythonhosted.org/packages/ad/64/1099084590cddebb5fad041a4fa36fd27ea7f37a9616fad869821ad2434a/lief-0.17.2-cp311-cp311-win32.whl", hash = "sha256:80acf55757a3e55ca94f83874b139d8aa2f0d07f055cf962e814819cb992b632", size = 3450916, upload-time = "2026-01-03T17:00:25.627Z" }, + { url = "https://files.pythonhosted.org/packages/da/08/0e1caf2af3ace7ea2f60d2e8a7a9746f215c70d16e1c93f8044e2c6abe91/lief-0.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:8e31ebc7ae82d147c12be7694ca59213c6d533327ee7d3ed68a0d6314438e48f", size = 3628501, upload-time = "2026-01-03T17:00:27.548Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d5/f7e39e9103a7304fe058a1413b5bf9c50c1f8f86e39f0ee51f22c2428e3c/lief-0.17.2-cp311-cp311-win_arm64.whl", hash = "sha256:9dff74f7ea8ce7667c809d8ad5e4fd2d85b7aed68ccd21c428c6de6ca925f52a", size = 3469459, upload-time = "2026-01-03T17:00:29.575Z" }, + { url = "https://files.pythonhosted.org/packages/47/09/b1e9339265c36dcf8d858ae48e44c6a7a5664abe13575c50d1687c3fbee7/lief-0.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4fc79c30c901a7359810b70d8851183b638dd1da08f2b4830d2581cf0a570c2", size = 2994993, upload-time = "2026-01-03T17:00:31.259Z" }, + { url = "https://files.pythonhosted.org/packages/8b/28/9c604f2ccfeaa5e1c1eac54db4c4b0be331ce57df4bda26e4aeb8ebb3e51/lief-0.17.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:4cd64a7a564fd1a18e8a4546d51ba94f42e04b9b5988f23d076b5590974b0814", size = 3107796, upload-time = "2026-01-03T17:00:33.355Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/94c856f35d17d35fcbe64623a1e142c86a0261e60a6e38fa67a721884d20/lief-0.17.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:299b8b4617110c55870412e2f7091de2f01d4453590cee211494f9d247b49f4b", size = 3669605, upload-time = "2026-01-03T17:00:34.881Z" }, + { url = "https://files.pythonhosted.org/packages/12/88/9fad956fe8c4878acf93dc8da74f73bcf79917e334172ba3b87f10cebed3/lief-0.17.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:f486d20e9511874986f7d3031f657cdd4bf5355d54660dbafd849ff082a09004", size = 3502208, upload-time = "2026-01-03T17:00:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/f3/af/f2f646e83381b6955095129e76380a63310bc8964526ba8825e45ae070b0/lief-0.17.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:38df6ec1a222faa507e9e5dbdd153384168161a492af88cb9259569809220e4e", size = 3405377, upload-time = "2026-01-03T17:00:38.045Z" }, + { url = "https://files.pythonhosted.org/packages/46/74/3835f85ea351eba1a8a7e0295b67203091c0fbe5de6756bb32414aeade33/lief-0.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e7a4ea7d882c95a24ec574f16ec6a297a698cc8685f8c5f43d8e2a7c709012a", size = 3591633, upload-time = "2026-01-03T17:00:39.756Z" }, + { url = "https://files.pythonhosted.org/packages/b6/45/74f6f236c67db6cc4458f1e85bbccc9248afde4e09a44636436d8561abe8/lief-0.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:47d24f09322f94ad425c89cac57a05a1d0c79340363c218680202020f021d727", size = 3959834, upload-time = "2026-01-03T17:00:41.804Z" }, + { url = "https://files.pythonhosted.org/packages/7b/de/20f9cb069a3074429a42e4324106908ec10d4fe943d32b0e54bfdd122003/lief-0.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cebfa8d2c763771d0f89b06f3b0d47c4670a7dfc05471eed87ae6046c1f0b2bf", size = 3706766, upload-time = "2026-01-03T17:00:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/1fba8e09c6fe1251ee29dbc90641a8636f9fd19c63c6cc62683ba72ba389/lief-0.17.2-cp312-cp312-win32.whl", hash = "sha256:aa267a4950f9aa721e431c25a4032275b4e879ff9d265465d42db43ba8fefaaa", size = 3460382, upload-time = "2026-01-03T17:00:44.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/a2/54754a402aa50c51e82e16e17223358f56f9608de5505e1520da91d33515/lief-0.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:594ab820b46ad1e411ff476ad8e87bb2f453a0011d16c936912e4774d40891bb", size = 3639558, upload-time = "2026-01-03T17:00:47.075Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/ccd8b1de1bc5324f795acf47bc791760cde404b198da9652ac6e2fe18bc1/lief-0.17.2-cp312-cp312-win_arm64.whl", hash = "sha256:c0c0332b4c20a0aee9712cf19b2b51188f75755a86431d29a9724fcb2d8a8106", size = 3473393, upload-time = "2026-01-03T17:00:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0a/0c7f940cd43f0bab208d61f58762433337d190b95e4806ea51eb2c84502c/lief-0.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:534aedbc7ccb38a989e0c9c6029e1c195bc7e7d6ae75c6cf484a6b62eff526b0", size = 3001378, upload-time = "2026-01-03T17:00:51.014Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/f5857447cac22734d75f6711ea8483b2f0ee2ddd287e75cb182755a4b733/lief-0.17.2-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:eb6c49157dc84e23d3eccbfc95491b07d08e81bf590f31237cca173926c27e4a", size = 3107463, upload-time = "2026-01-03T17:00:52.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/2b/2acffe4f48d7092dfa3b7b0a620e424888f3016c5ffabeb1bd1b1a844c37/lief-0.17.2-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:9183ef07238ae5be6d052dbc464a8350568fd4444957ab49646c5e0080774bb9", size = 3674544, upload-time = "2026-01-03T17:00:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/70/f2/494353cc7432ef93229bbed6d0e2dbcb7439e1dfa2e38ad7b5bc43d0fcdf/lief-0.17.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:57a76fed9eb47d81564e306eb9b34b4fcb765f2a5df85772a217436ed134d19f", size = 3502088, upload-time = "2026-01-03T17:00:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/50/77/6e8d2f972fa365d9e388cb140d9d474ab8b9e569584c3d9877197c4fb348/lief-0.17.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4d218e6681aa6272439f97788fe8855f043d1e4f2d8cddf475d262f9b28dddfa", size = 3404432, upload-time = "2026-01-03T17:00:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/b48c17cec03d1ec49b72dc809b0adb395a9b99f056e0578c77c80e99d5d3/lief-0.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:24d254f9b959e80cc7e989c7cd2cb4a75338a7c6e728118a0326ec260414e2fa", size = 3592586, upload-time = "2026-01-03T17:00:59.741Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/3ffa40ca3afdcea47741fc499be7cefb8ec3df109dbf5ccd6fda207d2649/lief-0.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:56e3f82c94f3a8df71e923e1eda860fc0333ed0749c21e1510e06975557322dc", size = 3959418, upload-time = "2026-01-03T17:01:01.428Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b1/ea7e8ade5a7c28846f753a90c3b8590c85cbbf7353860ac923ec5bd6050b/lief-0.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f08b77b6ca6c03800abf55d2754cc8cdf273fe440ec93e2c67600b3c96541f2e", size = 3706764, upload-time = "2026-01-03T17:01:03.146Z" }, + { url = "https://files.pythonhosted.org/packages/8d/00/d14420c05239d82b3607b0e103fa36e8331f1094ef628e31a916abcf0b5c/lief-0.17.2-cp313-cp313-win32.whl", hash = "sha256:48aa1e97bd532b50ebe168673f9f734dc01bcbf0d6106ecb71eaec3081bea826", size = 3460224, upload-time = "2026-01-03T17:01:04.977Z" }, + { url = "https://files.pythonhosted.org/packages/8e/99/98c012081ae563963156d9128fc6206e0c1620aef56b72843674eedf9b98/lief-0.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:fdee58d603d96ae0d4313d8b17afde8e320a5fb6a21eb005e724d21f1af4601f", size = 3639619, upload-time = "2026-01-03T17:01:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/de/db/1c7b86962d723575702f0a7ea2bb96a18a340baed703a397f79708d98219/lief-0.17.2-cp313-cp313-win_arm64.whl", hash = "sha256:66a02009f0b9bc8caa9850fd01d3b6d20f2f55416cea8fa068b878aa7476295f", size = 3472801, upload-time = "2026-01-03T17:01:08.279Z" }, + { url = "https://files.pythonhosted.org/packages/19/e4/753cc2d19644057325d9fba87cf60100cc6c6fc59ce3fdf9394f0264996f/lief-0.17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cb3e1c34905926ffd0371b27cfee4879956500680ed082f2e6f612dd73b372f9", size = 3000732, upload-time = "2026-01-03T17:01:10.519Z" }, + { url = "https://files.pythonhosted.org/packages/13/b9/c8dd617ce47e4315717936a025d85c526c9d41f3fdef16ec9d576a04b97c/lief-0.17.2-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:8f61ee95413e82f91721e1f15e8d0b187ebdf1488ac1f0677c16b9778d176875", size = 3109748, upload-time = "2026-01-03T17:01:12.727Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a4/2741c6b26416c0cba6cf5b031c5b9fe6b60565612946a6d498495408e04b/lief-0.17.2-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:52f93268a738a8653815e17b8c16b7292cfe509ad3eae397cc4d9df153242779", size = 3674276, upload-time = "2026-01-03T17:01:14.525Z" }, + { url = "https://files.pythonhosted.org/packages/95/ef/92907735807bbfde680c5866925f10a2dd6348071e36b623e0518dce5a5d/lief-0.17.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92f49c2159ecb9d3ab266e8e1685e63d223e8a3b2279657f10a8a98821053141", size = 3502277, upload-time = "2026-01-03T17:01:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/5e2646a779b9124f953385ca8875dff6e28400321ba676407f6d20a5ae72/lief-0.17.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4fe12e50be1c36dbecd637687ed406f6516614a6f0dc2455f84d3527671402f9", size = 3406749, upload-time = "2026-01-03T17:01:18.614Z" }, + { url = "https://files.pythonhosted.org/packages/a4/da/8cc6927edfe6424e7078ed185c6603791f2031e9c73c898613382307c6ed/lief-0.17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dc2f1e6eaae28aaebf7309c982775bb62da9ef83fbbb5a97370d99aff746c83f", size = 3591955, upload-time = "2026-01-03T17:01:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/67/3f/878e9bb619e0ed9cb55c69023ab1d9a2386576ec19be76180951080ce890/lief-0.17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7fa5855b72da404ba75e2f55a64ba8329b0651bc21129fa1aca2daa76918a249", size = 3959833, upload-time = "2026-01-03T17:01:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ca/a57cc1773d034dd20efa614069fae9580ebef3f8779e31c27c3512106a2c/lief-0.17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6bb5eefd4b43a421642a1e5aecb386f4c1deda1d03778c5ab4b26d9caf87b2c", size = 3708363, upload-time = "2026-01-03T17:01:24.985Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/c7e288278060ef3788dfbbb70698637e296b6f81fafc7ca0bf026cfae8e7/lief-0.17.2-cp314-cp314-win32.whl", hash = "sha256:e0f58a0c382e005c3649f2ba1d8bec2309e75f829427002f354ef60c91abffe9", size = 3460557, upload-time = "2026-01-03T17:01:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b9/d900723175296b3d0f4c80836752092e01d9a85d0c86bff9235ff808ed03/lief-0.17.2-cp314-cp314-win_amd64.whl", hash = "sha256:005a608fb9590929506bbcf368497fc39911368669aaff01544d9cc1b82e2460", size = 3639460, upload-time = "2026-01-03T17:01:28.851Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, + { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, + { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, + { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, + { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, + { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, + { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, + { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, + { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, + { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, + { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, + { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, + { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, + { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "patchelf" +version = "0.17.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/a3/fdd3fa938c864aa2f11dd0b7f08befeda983d2dcdee44da493c6977a653f/patchelf-0.17.2.4.tar.gz", hash = "sha256:970ee5cd8af33e5ea2099510b2f9013fa1b8d5cd763bf3fd3961281c18101a09", size = 149629, upload-time = "2025-07-23T21:16:32.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/a7/8c4f86c78ec03db954d05fd9c57a114cc3a172a2d3e4a8b949cd5ff89471/patchelf-0.17.2.4-py3-none-macosx_10_9_universal2.whl", hash = "sha256:343bb1b94e959f9070ca9607453b04390e36bbaa33c88640b989cefad0aa049e", size = 184436, upload-time = "2025-07-23T21:16:20.578Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6d/2e9f5483cdb352fab36b8076667b062b2d79cb09d2e3fd09b6fca5771cb6/patchelf-0.17.2.4-py3-none-manylinux1_i686.manylinux_2_5_i686.musllinux_1_1_i686.whl", hash = "sha256:09fd848d625a165fc7b7e07745508c24077129b019c4415a882938781d43adf8", size = 547318, upload-time = "2025-07-23T21:16:22.135Z" }, + { url = "https://files.pythonhosted.org/packages/7e/19/f7821ef31aab01fa7dc8ebe697ece88ec4f7a0fdd3155dab2dfee4b00e5c/patchelf-0.17.2.4-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:d9b35ebfada70c02679ad036407d9724ffe1255122ba4ac5e4be5868618a5689", size = 482846, upload-time = "2025-07-23T21:16:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/107fea848ecfd851d473b079cab79107487d72c4c3cdb25b9d2603a24ca2/patchelf-0.17.2.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2931a1b5b85f3549661898af7bf746afbda7903c7c9a967cfc998a3563f84fad", size = 477811, upload-time = "2025-07-23T21:16:25.145Z" }, + { url = "https://files.pythonhosted.org/packages/89/a9/a9a2103e159fd65bffbc21ecc5c8c36e44eb34fe53b4ef85fb6d08c2a635/patchelf-0.17.2.4-py3-none-manylinux2014_armv7l.manylinux_2_17_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:ae44cb3c857d50f54b99e5697aa978726ada33a8a6129d4b8b7ffd28b996652d", size = 431226, upload-time = "2025-07-23T21:16:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/897d612f6df7cfd987bdf668425127efeff8d8e4ad8bfbab1c69d2a0d861/patchelf-0.17.2.4-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:680a266a70f60a7a4f4c448482c5bdba80cc8e6bb155a49dcc24238ba49927b0", size = 540276, upload-time = "2025-07-23T21:16:27.983Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/2b92d11533482bac9ee989081d6880845287751b5f528adbd6bb27667fbd/patchelf-0.17.2.4-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.musllinux_1_1_s390x.whl", hash = "sha256:d842b51f0401460f3b1f3a3a67d2c266a8f515a5adfbfa6e7b656cb3ac2ed8bc", size = 596632, upload-time = "2025-07-23T21:16:29.253Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/975d4bdb418f942b53e6187b95bd9e0d5e0488b7bc214685a1e43e2c2751/patchelf-0.17.2.4-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:7076d9e127230982e20a81a6e2358d3343004667ba510d9f822d4fdee29b0d71", size = 508281, upload-time = "2025-07-23T21:16:30.865Z" }, +] + +[[package]] +name = "pecos-rslib" +version = "0.8.0.dev8" +source = { editable = "../../python/pecos-rslib" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "patchelf", marker = "sys_platform != 'win32'" }] +numpy-compat = [ + { name = "numpy", specifier = ">=1.20" }, + { name = "scipy", specifier = ">=1.7" }, +] +test = [{ name = "pytest", specifier = ">=9.0" }] + +[[package]] +name = "pecos-rslib-llvm" +version = "0.8.0.dev8" +source = { editable = "../../python/pecos-rslib-llvm" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [] +test = [{ name = "pytest", specifier = ">=9.0" }] + +[[package]] +name = "phir" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/34/89dc01d1e6dd8c7f56c17c4a8dd7e5afb60184057daf860dda995b951be0/phir-0.3.3.tar.gz", hash = "sha256:18159b1ac342d4af7e6cca7a3b949371b325a7f3c4f20ac1f5e3173a33a56033", size = 24517, upload-time = "2024-05-06T10:49:47.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/1d/ed9b8b43ee55c2fcb47dfc13415db1534d521a08b0ba30780d619c1b8523/phir-0.3.3-py3-none-any.whl", hash = "sha256:af6d64088279fb22320815f4fa8e42880fcc32416bedbcd8a5adc6c0eee3e567", size = 12356, upload-time = "2024-05-06T10:49:46.349Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytket" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphviz" }, + { name = "jinja2" }, + { name = "lark" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "qwasm" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sympy" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/8d/7b860651db750067666a74400812b7610847ecd604636574b980a4400b9a/pytket-2.18.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ab80518ee545bc44dff7f40fb68d46cfa946a0d86cbe47227e0c704c57819391", size = 5549168, upload-time = "2026-05-15T08:57:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cb/5a2ca0e39fa3ffed7460ce446fd843824c82dc501966d067b8ab2e0e9988/pytket-2.18.0-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:392384e74f2df432428c12020eb358474cee201b0387eeddac51cace3b2bfd2f", size = 6238136, upload-time = "2026-05-15T08:57:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/ec940c12f8376686d24d0d7d51674a71e45d0441b50bdfada7260e65b6c3/pytket-2.18.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ada57f98fde63a43db5d49407cfda06b70c792c93c018bfee9fb1dd336e07eb6", size = 7583259, upload-time = "2026-05-15T08:57:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/5c/80/0c66b92d7b14c990e90f9d226d69cc1a9d59816b515795ffe63f43045ca3/pytket-2.18.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e50c5368516649e42723256a0b610112f688824fef9535e9adb31d83c1ac359", size = 8319658, upload-time = "2026-05-15T08:57:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/c4/41/87498ac87aa2b5e330b188f6b4614466b451ca64012c7586c5411adcc354/pytket-2.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:6d29b2d1b5277e47ad149b28d5bf13706dd2d3d0502ec577ca21cac4353cc499", size = 9790910, upload-time = "2026-05-15T08:57:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/47/cc/cfcc64549767dee84172ebb5904e1b69d6246099d6ae170b2c91e0e0002c/pytket-2.18.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f700a97c4caf2ac98c47a2c8e37b71b28b9b35ca9218bbb0357bba499b23eeaa", size = 5550955, upload-time = "2026-05-15T08:57:47.038Z" }, + { url = "https://files.pythonhosted.org/packages/27/78/69295578749e0724647d3ba927f4d5466c48dc89218019e3298acb36842d/pytket-2.18.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:9349e1f6faddbe315fe32dd42da8609899b923b607034ff97ae40cb7f5a3d40c", size = 6240572, upload-time = "2026-05-15T08:57:49.046Z" }, + { url = "https://files.pythonhosted.org/packages/37/e1/6d75b33869b710699e939ab0b5193477d5e0f2d825613b07febf3c16c020/pytket-2.18.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3af39d4f3ae3297de8d75773b940800820192e12392071dab7eb3b4c761857", size = 7584459, upload-time = "2026-05-15T08:57:50.761Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/9047f68f21a12f14ca0c1a4fde2e618935afc79f061e2288ded62c468dbb/pytket-2.18.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4817d27ff0ebb5481b64b86eb3ec759b619354c0dff5e4f1a4cfc6e4b888cf78", size = 8320126, upload-time = "2026-05-15T08:57:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/7dbbd09d136084cb07f4aff4fc8cae03791e16c026713e6a72b9b170c601/pytket-2.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:60d95245695257642853ff9b0dec31fd520c9c07cdba4d4fb429a861914b97fe", size = 9791813, upload-time = "2026-05-15T08:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/15/39/28cd32ee7606b1b252b29464ba9afaa2c59cfa4a21fea715c9ed150dfe4e/pytket-2.18.0-cp312-abi3-macosx_14_0_arm64.whl", hash = "sha256:9673824105eb781ae05c4c80d3cd6786f4ab58c5a5def5b9ca7ae890dce9fd2d", size = 5531855, upload-time = "2026-05-15T08:57:57.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/03df743d1349f649a96e26eab7726288b03e9a84d2ecaf6eb9f6e25760e5/pytket-2.18.0-cp312-abi3-macosx_15_0_x86_64.whl", hash = "sha256:c819bae62bd96020053ad872a7e85150509781c2a83d7a66a69b9c6b5f6329cb", size = 6223192, upload-time = "2026-05-15T08:57:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e5/9886826e5ccb5639e493567073e04dc2911972dd8ea095367305f546df13/pytket-2.18.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c805946d49d93d13bb3b4fe21f69e89be38f9b1d5739aea8f64cd1e70c85da4d", size = 7534626, upload-time = "2026-05-15T08:58:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/01/00/b50f3b11b92d5c74713d37ddb57e310a0fcc63eb13549007167985801286/pytket-2.18.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ca100f33ca3b56f8fe9f1d72c904067789cfc58bc084439f9aa409ec6621fc3", size = 8262137, upload-time = "2026-05-15T08:58:03.839Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ce/87fe3c1d0cb981f076d0cccde68043734ec57587a3f8049a675aae0e29e8/pytket-2.18.0-cp312-abi3-win_amd64.whl", hash = "sha256:9557d36cfe4b4376c36c071b0ac792038314ebec396df8252143ac4a1972a7b3", size = 9764538, upload-time = "2026-05-15T08:58:05.654Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "quantum-pecos" +version = "0.8.0.dev8" +source = { editable = "../../python/quantum-pecos" } +dependencies = [ + { name = "guppylang" }, + { name = "hugr" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pecos-rslib" }, + { name = "pecos-rslib-llvm" }, + { name = "phir" }, + { name = "selene-sim" }, + { name = "tket" }, +] + +[package.metadata] +requires-dist = [ + { name = "cupy-cuda12x", marker = "python_full_version >= '3.11' and extra == 'cuda12'", specifier = ">=13.0.0" }, + { name = "cupy-cuda13x", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=13.0.0" }, + { name = "cuquantum-python-cu12", marker = "python_full_version >= '3.11' and extra == 'cuda12'", specifier = ">=25.3.0" }, + { name = "cuquantum-python-cu13", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=25.9.0" }, + { name = "guppylang", specifier = ">=0.21.16,<0.22" }, + { name = "hugr", specifier = ">=0.16,<0.17" }, + { name = "matplotlib", marker = "extra == 'visualization'", specifier = ">=2.2.0" }, + { name = "networkx", specifier = ">=2.1.0" }, + { name = "pecos-rslib", editable = "../../python/pecos-rslib" }, + { name = "pecos-rslib-llvm", editable = "../../python/pecos-rslib-llvm" }, + { name = "phir", specifier = ">=0.3.3" }, + { name = "plotly", marker = "extra == 'visualization'", specifier = "~=5.9.0" }, + { name = "pytket-cutensornet", marker = "python_full_version >= '3.11' and extra == 'cuda12'", specifier = ">=0.12.0" }, + { name = "pytket-cutensornet", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=0.12.0" }, + { name = "quantum-pecos", extras = ["stim"], marker = "extra == 'all'", editable = "../../python/quantum-pecos" }, + { name = "quantum-pecos", extras = ["visualization"], marker = "extra == 'all'", editable = "../../python/quantum-pecos" }, + { name = "selene-sim", specifier = "~=0.2.0" }, + { name = "stim", marker = "extra == 'stim'", specifier = ">=1.15.0" }, + { name = "tket", specifier = ">=0.13,<0.14" }, +] +provides-extras = ["stim", "visualization", "all", "cuda13", "cuda12"] + +[[package]] +name = "qwasm" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/a1/b52f356f907bedf02f536970c1b46aa69bc57280c4d17ed6b5c39180959f/qwasm-1.0.1.tar.gz", hash = "sha256:01f5dfe27159b7fdd9d02cd299833225d528fa383d1278268e5e1526357950fb", size = 13921, upload-time = "2022-10-05T09:46:56.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/e9/fbde58bf8dcc05dc9d09dfcd06e631f470aa1e6732870cf06cd34ab86eaf/qwasm-1.0.1-py3-none-any.whl", hash = "sha256:c4c82a3f962d29314634868e06375f0cb4676c3d5266fbe137f6cd67321b0ef1", size = 15322, upload-time = "2022-10-05T09:46:54.856Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "selene-core" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pydot" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/16/677704c1037e65b979880c2b394b3e41dd7ac7cee97f86abee644a0cec8a/selene_core-0.2.2-py3-none-any.whl", hash = "sha256:1ebc8c14c268aab9529fe58ecf352164a49bda4a31b82008df22eaf1aee93362", size = 27548, upload-time = "2025-11-07T13:41:39.738Z" }, +] + +[[package]] +name = "selene-hugr-qis-compiler" +version = "0.2.10" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/aa/6b3f287f9330bf077c95bfef3ab350ab1eac0707f7111c38596a22554ecf/selene_hugr_qis_compiler-0.2.10-cp310-abi3-macosx_13_0_arm64.whl", hash = "sha256:2912702073aa37f1bfd1d42678b972b3b10525ed3f5f003091519512331142bd", size = 29532801, upload-time = "2025-11-10T14:48:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9c/77a1b81e1cedbde6f401fbdc2302bc2221c8cbc46c924ac0bd9cda979799/selene_hugr_qis_compiler-0.2.10-cp310-abi3-macosx_13_0_x86_64.whl", hash = "sha256:60ece08d7ffb792149029f8aad483c546d2f2db8a5d265906bed41479bdc5a9e", size = 32232684, upload-time = "2025-11-10T14:48:42.701Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d0/569e59983549fcf4c050f892927a55af92e0b0ab0622610c58ebbd72c03f/selene_hugr_qis_compiler-0.2.10-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fde73e325ad51bb00c40978a4a17ecb31ff968854d39259cee5a600bf2964b54", size = 32893561, upload-time = "2025-11-10T14:48:46.504Z" }, + { url = "https://files.pythonhosted.org/packages/92/b0/abf9cfe11934100a2210468545c7a8b8508534c1c90b882484a9b59fbf96/selene_hugr_qis_compiler-0.2.10-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9ecb054a543a41ecadcbfdd3d49ba9ada99e5d0806fb1b10638d3e9c1d6e6216", size = 33989527, upload-time = "2025-11-10T14:48:49.734Z" }, + { url = "https://files.pythonhosted.org/packages/12/36/476ac6ffdd5e8edc7d1f4772fb1eb3679e6e38105aa529a3d59a5d211bfa/selene_hugr_qis_compiler-0.2.10-cp310-abi3-win_amd64.whl", hash = "sha256:bf013a2b6910dbac1a811d95822df25d4bdcd96f15d9b1aa263b578834a45c27", size = 29199534, upload-time = "2025-11-10T14:48:52.545Z" }, +] + +[[package]] +name = "selene-sim" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hugr" }, + { name = "lief" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pydot" }, + { name = "pyyaml" }, + { name = "selene-core" }, + { name = "selene-hugr-qis-compiler" }, + { name = "tqdm" }, + { name = "ziglang" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/37/9fb5edc594c983fe352dfa9147643faaf86af4510a42018ae81aa40bdb32/selene_sim-0.2.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a846c854c1591831f92a47c75a22d60554bcf2d742d994d0e5b0d9fc01a5f574", size = 3906356, upload-time = "2026-01-08T21:06:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/5d3843877b120340a526af5106c80ab3f3417c77c66dc38458d312a75a71/selene_sim-0.2.7-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:3b8c01cb3a89397d04decfa65ce771bc4e6f6bdf52067768c683d3a13988fd34", size = 4009827, upload-time = "2026-01-08T21:06:08.988Z" }, + { url = "https://files.pythonhosted.org/packages/2b/50/c16e19c6895b53e21f67eb1c409030bd90836c7c75c15ba72dba8c0edb58/selene_sim-0.2.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0062eb50380df41876937e8478f4dca0d87fa5443f6981c66b010deada7706a5", size = 4372378, upload-time = "2026-01-08T21:06:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/e9/17/afefb21fa21bbc719a053b62a1c8df5f521c9bc5544c2efe6c946b582b17/selene_sim-0.2.7-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f2eb770b5224cf8a20d0622bdc4ee09251e8db71472acf58f949509eb7157a1f", size = 4416747, upload-time = "2026-01-08T21:06:11.899Z" }, + { url = "https://files.pythonhosted.org/packages/b6/de/3a09afbade0b6e2d283670469d9e0d787ba0b1f52a94a3864f12e0295e9a/selene_sim-0.2.7-py3-none-win_amd64.whl", hash = "sha256:4d9b4c0aab196a1d17d724771495152384313d724543b17522199004272f5a98", size = 2774835, upload-time = "2026-01-08T21:06:13.224Z" }, +] + +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tket" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hugr" }, + { name = "tket-eccs" }, + { name = "tket-exts" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/b0/b6f4659447c521c3b7fe066efa6b3204d2eb1129de8b31fb0ed2218d5596/tket-0.13.1.tar.gz", hash = "sha256:aaf21432939923962362b8783be8190e7f493ae5db3b783fdb93abd352d39ec4", size = 605360, upload-time = "2026-05-19T13:31:30.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d0/3c9a0d7152d3cf01efd76cf32ae92b79dca7acae14a44b64845ddeb6b6c2/tket-0.13.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2c64dc53ea903350491927260cfdaf8d77dd1400c786be9b83305b4b9dee2e96", size = 10441428, upload-time = "2026-05-19T13:31:18.158Z" }, + { url = "https://files.pythonhosted.org/packages/96/41/c00db203f438f14fb892a98387cc476e7c506ad7bff26a1756a53a07f173/tket-0.13.1-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:bcfcbc0f646f5f8c16e943819a1010aaaf312c79642ea6e737ad7b9559603f8b", size = 11378534, upload-time = "2026-05-19T13:31:20.779Z" }, + { url = "https://files.pythonhosted.org/packages/33/b0/90894ebb09b8eeda60da8205e74d892183007c93433e9ffab052a365abb7/tket-0.13.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d53963635634af92670537fa431450a58e36b79ffcc7d8c7b0731150443ed2ea", size = 12555543, upload-time = "2026-05-19T13:31:23.191Z" }, + { url = "https://files.pythonhosted.org/packages/bd/70/68edcddf60219455933938502377a5e224873ccdfdc9e2ed6381b01fb690/tket-0.13.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:172fd7b159129ff46652474e85bcceae2eabcf854a712ba465c25818964dc0ed", size = 13628232, upload-time = "2026-05-19T13:31:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f0/416bc9d59675a1330b2d08d2f975e40cf4a3d70de39776b4e7a99506e71c/tket-0.13.1-cp310-abi3-win_amd64.whl", hash = "sha256:7b8c16dbe4af5dbc5a4381c9d17cbba4b103fe60254273f1eec2de08b70736be", size = 10218380, upload-time = "2026-05-19T13:31:28.394Z" }, +] + +[package.optional-dependencies] +pytket = [ + { name = "pytket" }, +] + +[[package]] +name = "tket-eccs" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/35/0433336cbe94775e79d1e27823e95f05c697be0010c4c06252bc562cabc4/tket_eccs-0.5.1.tar.gz", hash = "sha256:7e3118569c64f29bab09b9c2dbf20b769ceb1db80ca9b2cec59dfae09023e7d2", size = 7615434, upload-time = "2025-08-19T16:55:12.648Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/56/ef17291924c26e02ce19d062d4698234f499c156a7ed670a0ec556a37d96/tket_eccs-0.5.1-py3-none-any.whl", hash = "sha256:a28f66fde5fe0f52f286a5aca19b821a3182328596d79c7d26c7ede7a4659537", size = 7616506, upload-time = "2025-08-19T16:55:10.576Z" }, +] + +[[package]] +name = "tket-exts" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hugr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/68/036b38ded9ad6ef8aedb93817127af0b5535e52eeb68ef8f19b368d0e035/tket_exts-0.12.3.tar.gz", hash = "sha256:de9faa87e35a7cc2250ab1022a026f129d05dc7bf41ad4fd86914d5a9c81541e", size = 21797, upload-time = "2026-04-07T15:26:51.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/df/ab123f36707d0fb4deaa96c48a992b90f574348f79aefb94c120dd695f1b/tket_exts-0.12.3-py3-none-any.whl", hash = "sha256:34b03fa69160606bb7ef86735671aa8e7a70149947d630c48a7ebf2f61c49417", size = 34309, upload-time = "2026-04-07T15:26:49.84Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "types-tqdm" +version = "4.67.0.20250809" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d0/cf498fc630d9fdaf2428b93e60b0e67b08008fec22b78716b8323cf644dc/types_tqdm-4.67.0.20250809.tar.gz", hash = "sha256:02bf7ab91256080b9c4c63f9f11b519c27baaf52718e5fdab9e9606da168d500", size = 17200, upload-time = "2025-08-09T03:17:43.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/13/3ff0781445d7c12730befce0fddbbc7a76e56eb0e7029446f2853238360a/types_tqdm-4.67.0.20250809-py3-none-any.whl", hash = "sha256:1a73053b31fcabf3c1f3e2a9d5ecdba0f301bde47a418cd0e0bdf774827c5c57", size = 24020, upload-time = "2025-08-09T03:17:42.453Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "wasmtime" +version = "38.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/79/3d055ce7a0a237941d1910f32a1b6692dee4021d0cb709a97d2feb3f1ef3/wasmtime-38.0.0.tar.gz", hash = "sha256:75d38a075571756543266df782979fc2204cafd1fb7f3ebbb901e05df916dd34", size = 147265, upload-time = "2025-10-20T21:03:50.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/7a/2714d2f9424b952d71ca88ac264352f0c3d3282cf62d93a3ce337b50624e/wasmtime-38.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:71acc1f48ba6addbd4aee72bb249025709b31bb6a655cbecf2972f3412a8c3b0", size = 7786963, upload-time = "2025-10-20T21:03:29.124Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fa/52965b32fa204dde06585641ba367313db46149c3ee15f05e21babcb2d96/wasmtime-38.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:c3dac04170a3fa4257ba0b0a04f360c09ddae4e67c1ceca144d757a372004e2c", size = 8596069, upload-time = "2025-10-20T21:03:31.694Z" }, + { url = "https://files.pythonhosted.org/packages/5c/56/1ba8709083dbdbd0f734921704513d45fcd855e4406ae0f4a7d06c78c7e5/wasmtime-38.0.0-py3-none-any.whl", hash = "sha256:df99e75296a504d37053bc0cae3a73cc5ec5c1942d8f086a4195afaa26274457", size = 7160166, upload-time = "2025-10-20T21:03:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/c470e5be91cccf7fbc4db4ce42eafb8f0f34b45aeb92c5bfbf963d100e0b/wasmtime-38.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:1c46037114350f25b23eb928085b29dc8acfe4de7ba6a59584b51bfc8533e14c", size = 8340326, upload-time = "2025-10-20T21:03:37.414Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/83d598af08eb297f1a644e678ce2d6c335f015c9f3c8c2532e289801293b/wasmtime-38.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:479529425f68cd69f8f4c369abf50d6f44a0e2395f5ce2b9a506c8a695aef65b", size = 7457077, upload-time = "2025-10-20T21:03:39.876Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/7b8898c9e7f73a2f4c20b621279143cfc27836dba0db676e9a923cd0a461/wasmtime-38.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:f6c87bfe9de9a35de5b28fbdac192e2526486e3d0a3be48e107789cf117925fd", size = 8676002, upload-time = "2025-10-20T21:03:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/8e6f9670ea1476735517b28b99b2595e9000d46ee0a276d0e0b547cc928c/wasmtime-38.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d5455bd83e82b04db32c226524b1c084a71d5d9d4ada3c5d0cf5cccaf2563253", size = 7725439, upload-time = "2025-10-20T21:03:44.34Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/b2b024d203dd676811729ad8de232608b2e727a89cd5ad16fbdeeac98dd7/wasmtime-38.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3864469deb899eda7f32193d8cc8922d6b320cc016d08e601236dbfd77b096fe", size = 8706404, upload-time = "2025-10-20T21:03:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/66/99/6da689c6534b16b2e6959321533f0000228854e5f4211eb00bfa7b6bcebc/wasmtime-38.0.0-py3-none-win_amd64.whl", hash = "sha256:c0f7ac800575e592e74ce7d111e04ad7b3dbbff421bfac00c94432300ee39bfe", size = 7160172, upload-time = "2025-10-20T21:03:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/a1/85/fbba5850c65ab91312a68871dec4e36abbb7ad717f3df6c790bff330b054/wasmtime-38.0.0-py3-none-win_arm64.whl", hash = "sha256:79fd37228ded91e84ee6748894ef4686bd6232e635e150e6a70beabd99c67868", size = 6272297, upload-time = "2025-10-20T21:03:48.882Z" }, +] + +[[package]] +name = "ziglang" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/4d/cf640c988231f36c796f7310cf54e89866c96e63cd6599bac4bbbdcf7014/ziglang-0.15.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:f2f92404599822152eff2ef2830632e9ebeb18e55168dddd5f68f6bfeb2c5f4d", size = 93002414, upload-time = "2025-08-30T03:57:24.477Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/4375a5c27ad3fd72d7a7ba2f013bdd9a99f9e0dae847cbfdfe56f9cb0f0b/ziglang-0.15.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:77348af083caf1c364466b931b5e7f9608687c88670bdda77c237570096bde09", size = 97045379, upload-time = "2025-08-30T03:57:36.813Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/d6fd19f278745ae187ca46e907f83b4c83142f5f6c5de8f159879cc70d4b/ziglang-0.15.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:129c6b9b9e428ae48a6949ea6da55239f8bd6480656df1eb0b6947f75f851fdf", size = 97295023, upload-time = "2025-08-30T03:57:48.76Z" }, + { url = "https://files.pythonhosted.org/packages/30/a0/8aabb5f4e0862340ebb4d86f614567a5492e4e1ac09639c9c759404babe1/ziglang-0.15.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:4e45994a0e608d9b16ecad255698f5557a2e24de0bd7ba9efb156ab3f3683d9a", size = 93506712, upload-time = "2025-08-30T03:58:00.996Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0b/ea4ac1a1242c478c7ccbf0c031c9f6f58290c5ea910490fc0b5a3772648e/ziglang-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6c32697f9e165b7b6c5950ab0a1cd2e2bc3e72f4ff2d59bc5121b2b71955a77a", size = 90536567, upload-time = "2025-08-30T03:58:12.841Z" }, + { url = "https://files.pythonhosted.org/packages/bf/12/89d3ac45c7072de940328c5bf52e9846797237423ec4415b5c7e7775a2e1/ziglang-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:f9d2493ff7c44967c584212100ce57bb00800ec9545527acfce677b4b3225242", size = 91572198, upload-time = "2025-09-11T21:51:29.45Z" }, + { url = "https://files.pythonhosted.org/packages/08/a2/89539bbe0ad375cb72b788ff11724f408f870a045c7d8dc9451066bd6526/ziglang-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:b261fe992100fdfb3e61cdd0758335ac8514c8aa4029e3604490648c6a337466", size = 99939660, upload-time = "2025-08-30T03:58:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/cc/19/95c05b330c70275c79cd1964e9651d87c67876ebc70d148432748f629b95/ziglang-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.musllinux_1_1_s390x.whl", hash = "sha256:9118903a47bbcc747ce47b1456c552a04bb6a0e1be28275ab20bbccf8104e474", size = 99578982, upload-time = "2025-08-30T03:58:39.312Z" }, + { url = "https://files.pythonhosted.org/packages/f0/10/e1f17be5cdbaae6932c110b1ce3d877fbaeeb40b34a18390324219110da1/ziglang-0.15.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6a49c03d692e31a9a312ec45c0829bc281572196a9df52318bb0be0d05ae20ea", size = 94114681, upload-time = "2025-08-30T03:58:51.877Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9b/5b11a0f78ab3475e25c0e53e240e39b2c2fcfe1e651f639834860755d7cf/ziglang-0.15.1-py3-none-win32.whl", hash = "sha256:b8ba52adc1401c470707a420f2e5e199fce142436717aa822e00a93a18a9ea25", size = 96132500, upload-time = "2025-08-30T03:59:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f3/7b0fb6f1f7705221694f90c4756480646c1a08f6ae94e9f567ac4d1c4d17/ziglang-0.15.1-py3-none-win_amd64.whl", hash = "sha256:dae4c6aef5bf9d64f6eb71ae57603e2fd0ad5e79efdd5ca3ea058fb1e738d961", size = 94047515, upload-time = "2025-08-30T03:59:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0a/6b15293c30f5f555f1313010855d588004db1166af51bbc697a271cc1ce2/ziglang-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5965248dd7f72769ff339a04bd8e29e13fa205758c64766ef9cc55eaafbaedb8", size = 89692347, upload-time = "2025-08-30T03:59:31.195Z" }, +] + +[[package]] +name = "zluppy" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "patchelf", marker = "sys_platform != 'win32'" }, + { name = "pytest" }, + { name = "quantum-pecos" }, +] +test = [ + { name = "pytest" }, + { name = "quantum-pecos" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "patchelf", marker = "sys_platform != 'win32'" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "quantum-pecos", editable = "../../python/quantum-pecos" }, +] +test = [ + { name = "pytest", specifier = ">=7.0" }, + { name = "quantum-pecos", editable = "../../python/quantum-pecos" }, +] diff --git a/mkdocs.yml b/mkdocs.yml index 521043e26..40b9679f1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,6 +90,7 @@ nav: - AST Infrastructure: development/ast-infrastructure.md - Foreign Language Plugins: development/foreign-plugins.md - Parallel Blocks: development/parallel-blocks-and-optimization.md + - Noise Event Replay: development/noise-event-replay.md - Experimental: - experimental/index.md - Composable Noise (pecos-neo): experimental/composable-noise.md diff --git a/osv-scanner.toml b/osv-scanner.toml index 0a2685200..2b7b40a28 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -72,3 +72,45 @@ id = "RUSTSEC-2022-0104" # Chain: pecos-mwpf -> mwpf -> slp -> structopt 0.3. # Upstream owner: https://github.com/yuewuo/mwpf. reason = "Transitive via mwpf -> slp -> structopt; maintenance-mode warning for upstream CLI parser, no direct PECOS use." + +[[IgnoredVulns]] +id = "PYSEC-2026-151" +# wasmtime (PyPI) 42.0.0 (uv.lock) / 38.0.0 (exp/zluppy/uv.lock) -- Wasmtime +# stack-overflow bug class; the advisory lists NO fixed release (last_affected +# 43.0.0, the latest). +# Chain: quantum-pecos -> guppylang -> guppylang-internals -> wasmtime. +# Upstream owner: https://github.com/bytecodealliance/wasmtime-py. +reason = "Transitive via guppylang; no fixed wasmtime-py release exists yet. PECOS only runs trusted, locally-generated Wasm through it. Re-check on guppylang bumps." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0075" +# unic-char-range@0.9.0 -- unmaintained (whole unic project dormant). +# Chain: guppy-zlup -> rustpython-parser 0.4 -> unic-*. +# Upstream owner: https://github.com/RustPython/Parser (itself archived; the +# long-term fix is guppy-zlup moving off rustpython-parser). +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0080" +# unic-common@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0081" +# unic-char-property@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0090" +# unic-emoji-char@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0098" +# unic-ucd-version@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0100" +# unic-ucd-ident@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs index bf1a8fe67..e3ea3b0e5 100644 --- a/python/pecos-rslib-exp/src/lib.rs +++ b/python/pecos-rslib-exp/src/lib.rs @@ -70,6 +70,13 @@ fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(sim_neo_bindings::py_sim_neo, m)?)?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::monte_carlo, m)?)?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::path_enumeration, m)?)?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::subset_simulation, m)?)?; m.add_function(wrap_pyfunction!(sim_neo_bindings::stab_mps, m)?)?; m.add_function(wrap_pyfunction!(sim_neo_bindings::depolarizing, m)?)?; m.add_class::()?; diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 57fd31c7f..d237a0df4 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -17,7 +17,7 @@ //! results = (sim_neo(tc) //! .quantum(stab_mps().lazy_measure().max_bond_dim(128)) //! .noise(depolarizing().p1(0.003).p2(0.003).p_meas(0.003).p_prep(0.003).idle_rz(0.05)) -//! .shots(5000) +//! .sampling(monte_carlo(5000)) //! .seed(42) //! .run()) //! ``` @@ -60,6 +60,13 @@ fn measurement_record_index(record: i32, num_measurements: usize) -> Option>, + /// Rare-event estimate (subset simulation only; rows are empty for + /// subset runs). Mirrors Rust `SimulationResults::subset`. + subset: Option>, } enum RawMeasurementStorage { @@ -135,6 +142,8 @@ impl PyRawMeasurementResult { pub fn from_columnar(result: SampleResult) -> Self { Self { storage: RawMeasurementStorage::Columnar(result), + weights: None, + subset: None, } } @@ -146,8 +155,24 @@ impl PyRawMeasurementResult { rows, num_measurements, }, + weights: None, + subset: None, } } + + /// Construct from row-major data with per-row weights. + pub fn from_rows_weighted(rows: Vec>, weights: Vec) -> Self { + let mut result = Self::from_rows(rows); + result.weights = Some(weights); + result + } + + /// Construct a subset-simulation result (no rows; estimate only). + pub fn from_subset(subset: Py) -> Self { + let mut result = Self::from_rows(Vec::new()); + result.subset = Some(subset); + result + } } #[pymethods] @@ -164,6 +189,21 @@ impl PyRawMeasurementResult { self.storage.num_measurements() } + /// Per-row weights, or None for plain Monte Carlo runs. + /// + /// Path enumeration: exact path probabilities (sum to 1 for complete + /// enumeration). + #[getter] + fn weights(&self) -> Option> { + self.weights.clone() + } + + /// Rare-event estimate, or None unless run with subset simulation. + #[getter] + fn subset(&self, py: Python<'_>) -> Option> { + self.subset.as_ref().map(|s| s.clone_ref(py)) + } + /// Get a single measurement bit (0 or 1). fn get(&self, shot: isize, measurement: isize) -> PyResult { let s = Self::check_index(shot, self.storage.num_shots(), "shot")?; @@ -308,7 +348,7 @@ pub fn depolarizing() -> PyNoiseModelBuilder { /// Pass to `.quantum()` to select the stabilizer simulator. /// /// Example: -/// sim_neo(tc).quantum(stabilizer()).noise(depolarizing().p2(0.01)).shots(10000).run() +/// sim_neo(tc).quantum(stabilizer()).noise(depolarizing().p2(0.01)).sampling(monte_carlo(10000)).run() #[pyclass( name = "StabilizerBuilder", skip_from_py_object, @@ -331,7 +371,7 @@ impl PyStabilizerBuilder { /// Supports arbitrary gates including non-Clifford (T, RZ, etc.). /// /// Example: -/// sim_neo(tc).quantum(statevec()).noise(depolarizing().idle_rz(0.05)).shots(10000).run() +/// sim_neo(tc).quantum(statevec()).noise(depolarizing().idle_rz(0.05)).sampling(monte_carlo(10000)).run() #[pyclass( name = "StateVecBuilder", skip_from_py_object, @@ -351,7 +391,7 @@ impl PyStateVecBuilder { /// Create a state vector backend builder. /// /// Example: -/// sim_neo(tc).quantum(statevec()).noise(...).shots(10000).run() +/// sim_neo(tc).quantum(statevec()).noise(...).sampling(monte_carlo(10000)).run() #[pyfunction] pub fn statevec() -> PyStateVecBuilder { PyStateVecBuilder @@ -360,7 +400,7 @@ pub fn statevec() -> PyStateVecBuilder { /// Create a stabilizer (SparseStab) backend builder. /// /// Example: -/// sim_neo(tc).quantum(stabilizer()).noise(...).shots(10000).run() +/// sim_neo(tc).quantum(stabilizer()).noise(...).sampling(monte_carlo(10000)).run() #[pyfunction] pub fn stabilizer() -> PyStabilizerBuilder { PyStabilizerBuilder @@ -460,6 +500,249 @@ pub fn meas_sampling(method: &str) -> PyMeasSamplingBuilder { PyMeasSamplingBuilder::new(method) } +/// Monte Carlo sampling strategy builder. Mirrors Rust `monte_carlo(shots)`. +/// +/// Example: +/// sim_neo(tc).sampling(monte_carlo(1000).workers(4)).run() +#[pyclass( + name = "MonteCarloBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PyMonteCarloBuilder { + pub(crate) shots: usize, + pub(crate) workers: usize, +} + +#[pymethods] +impl PyMonteCarloBuilder { + /// Set the number of parallel workers (1 = sequential). + fn workers(&self, n: usize) -> Self { + let mut c = self.clone(); + c.workers = n; + c + } +} + +/// Create a Monte Carlo sampling strategy running `shots` shots. +/// +/// Sequential by default; chain `.workers(n)` for parallel execution. +#[pyfunction] +pub fn monte_carlo(shots: usize) -> PyMonteCarloBuilder { + PyMonteCarloBuilder { shots, workers: 1 } +} + +/// Path enumeration strategy builder. Mirrors Rust `path_enumeration(k)`. +/// +/// Exhaustively enumerates the measurement branches of a noiseless Clifford +/// circuit. Each distinct realized path becomes one result row; exact path +/// probabilities are exposed via `result.weights`. +/// +/// Example: +/// result = sim_neo(tc).quantum(stabilizer()).sampling(path_enumeration(2)).run() +/// for row, p in zip(result, result.weights): ... +#[pyclass( + name = "PathEnumerationBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PyPathEnumerationBuilder { + pub(crate) max_measurements: usize, +} + +/// Create a path enumeration strategy covering up to `max_measurements` +/// random measurement branches. +#[pyfunction] +pub fn path_enumeration(max_measurements: usize) -> PyPathEnumerationBuilder { + PyPathEnumerationBuilder { max_measurements } +} + +/// Subset simulation strategy builder. Mirrors Rust `subset_simulation(n)`. +/// +/// Estimates rare event probabilities by decomposing them into conditional +/// probabilities across adaptive levels. Requires a `.score(fn)` (how close +/// is this outcome to failure?) and a `.failure(fn)` predicate; both receive +/// the measurement bits of one sample as `list[int]` and are called once per +/// sample (Python-callable cost applies). +/// +/// Example: +/// result = (sim_neo(tc) +/// .quantum(stabilizer()) +/// .sampling(subset_simulation(1000) +/// .score(lambda bits: float(sum(bits))) +/// .failure(lambda bits: all(bits))) +/// .seed(42) +/// .run()) +/// print(result.subset.probability) +#[pyclass( + name = "SubsetSimulationBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +pub struct PySubsetSimulationBuilder { + samples_per_level: usize, + threshold_fraction: f64, + max_levels: usize, + min_conditional_prob: f64, + allow_biased_multilevel: bool, + score: Option>, + failure: Option>, +} + +impl Clone for PySubsetSimulationBuilder { + fn clone(&self) -> Self { + Python::attach(|py| Self { + samples_per_level: self.samples_per_level, + threshold_fraction: self.threshold_fraction, + max_levels: self.max_levels, + min_conditional_prob: self.min_conditional_prob, + allow_biased_multilevel: self.allow_biased_multilevel, + score: self.score.as_ref().map(|f| f.clone_ref(py)), + failure: self.failure.as_ref().map(|f| f.clone_ref(py)), + }) + } +} + +#[pymethods] +impl PySubsetSimulationBuilder { + /// Set the score function: bits (`list[int]`) -> float. + /// + /// Higher scores advance to the next level; failing outcomes should + /// score at least as high as any non-failing outcome. + fn score(&self, f: Py) -> Self { + let mut c = self.clone(); + c.score = Some(f); + c + } + + /// Set the failure predicate: bits (`list[int]`) -> bool. + fn failure(&self, f: Py) -> Self { + let mut c = self.clone(); + c.failure = Some(f); + c + } + + /// Fraction of samples that advances past each threshold (default 0.1). + fn threshold_fraction(&self, fraction: f64) -> Self { + let mut c = self.clone(); + c.threshold_fraction = fraction; + c + } + + /// Maximum number of levels before giving up. + /// + /// Defaults to 1 (a single, unbiased direct-Monte-Carlo level). + /// Setting more than one level engages the multi-level estimator, + /// which is currently biased upward, and therefore also requires an + /// explicit `.allow_biased_multilevel()` acknowledgment, or `.run()` + /// raises. + fn max_levels(&self, levels: usize) -> Self { + let mut c = self.clone(); + c.max_levels = levels; + c + } + + /// Acknowledge and accept the known upward bias of the multi-level + /// subset estimator, enabling `max_levels > 1`. Without it, subset + /// simulation runs a single unbiased level (direct Monte Carlo). + fn allow_biased_multilevel(&self) -> Self { + let mut c = self.clone(); + c.allow_biased_multilevel = true; + c + } + + /// Minimum conditional probability before declaring the failure event + /// unreachable (default 1e-6). + fn min_conditional_prob(&self, p: f64) -> Self { + let mut c = self.clone(); + c.min_conditional_prob = p; + c + } +} + +/// Create a subset simulation strategy running `samples_per_level` samples +/// at each level. `.score(..)` and `.failure(..)` are required. +#[pyfunction] +pub fn subset_simulation(samples_per_level: usize) -> PySubsetSimulationBuilder { + let defaults = pecos_neo::sampling::subset::SubsetConfig::default(); + PySubsetSimulationBuilder { + samples_per_level, + threshold_fraction: defaults.threshold_fraction, + // Default to a single, unbiased level; the biased multi-level path + // requires an explicit .allow_biased_multilevel() opt-in. + max_levels: 1, + min_conditional_prob: defaults.min_conditional_prob, + allow_biased_multilevel: false, + score: None, + failure: None, + } +} + +/// Rare-event estimate from subset simulation. Mirrors Rust `SubsetResult`. +#[pyclass(name = "SubsetResult", module = "pecos_rslib_exp")] +pub struct PySubsetResult { + inner: pecos_neo::sampling::subset::SubsetResult, +} + +#[pymethods] +impl PySubsetResult { + /// Overall probability estimate. + #[getter] + fn probability(&self) -> f64 { + self.inner.probability() + } + + /// Coefficient of variation (standard error / estimate). + #[getter] + fn coefficient_of_variation(&self) -> f64 { + self.inner.coefficient_of_variation + } + + /// Total number of samples run across all levels. + #[getter] + fn total_samples(&self) -> usize { + self.inner.total_samples + } + + /// Number of failures observed directly. + #[getter] + fn direct_failures(&self) -> usize { + self.inner.direct_failures + } + + /// 95% confidence interval (assuming log-normal): (lower, upper). + fn confidence_interval_95(&self) -> (f64, f64) { + self.inner.confidence_interval_95() + } + + /// Per-level statistics as a list of dicts. + fn levels<'py>(&self, py: Python<'py>) -> PyResult> { + use pyo3::types::{PyDict, PyList}; + let list = PyList::empty(py); + for level in &self.inner.levels { + let d = PyDict::new(py); + d.set_item("level", level.level)?; + d.set_item("threshold", level.threshold)?; + d.set_item("num_samples", level.num_samples)?; + d.set_item("num_exceeded", level.num_exceeded)?; + d.set_item("conditional_prob", level.conditional_prob)?; + d.set_item("num_failures", level.num_failures)?; + list.append(d)?; + } + Ok(list) + } +} + +/// Sampling strategy selected on the Python builder. +#[derive(Clone)] +enum PySampling { + MonteCarlo { shots: usize, workers: usize }, + PathEnumeration { max_measurements: usize }, + SubsetSimulation { config: PySubsetSimulationBuilder }, +} + /// Builder for sim_neo simulations. Mirrors the Rust-side `SimNeoBuilder`. #[pyclass( name = "SimNeoBuilder", @@ -472,10 +755,18 @@ pub struct PySimNeoBuilder { /// Original Rust TickCircuit for meas_sampling (avoids reconstruction). /// Wrapped in Arc for Clone compatibility with pyo3. tick_circuit: std::sync::Arc, - shots: usize, - seed: u64, + /// Sampling strategy. None until `.sampling()`. + sampling: Option, + /// Shot count from the deprecated top-level `.shots()` forwarder. + legacy_shots: Option, + /// Random seed. None = nondeterministic, mirroring the Rust builder. + seed: Option, + /// Backend auto-selection opt-in from `.auto()`. + auto: bool, noise_config: Option, - backend: String, + /// Backend name. None until `.quantum()` is called; `.auto()` opts into + /// automatic selection at run time. + backend: Option, stabmps_config: Option, meas_sampling_method: Option, } @@ -497,20 +788,20 @@ impl PySimNeoBuilder { let mut c = self.clone(); if builder.is_instance_of::() { let b: PyRef<'_, PyMeasSamplingBuilder> = builder.extract()?; - c.backend = "meas_sampling".to_string(); + c.backend = Some("meas_sampling".to_string()); c.meas_sampling_method = Some(b.method.clone()); c.stabmps_config = None; } else if builder.is_instance_of::() { let b: PyRef<'_, PyStabMpsBuilder> = builder.extract()?; - c.backend = "stabmps".to_string(); + c.backend = Some("stabmps".to_string()); c.stabmps_config = Some(b.inner.clone()); c.meas_sampling_method = None; } else if builder.is_instance_of::() { - c.backend = "stabilizer".to_string(); + c.backend = Some("stabilizer".to_string()); c.stabmps_config = None; c.meas_sampling_method = None; } else if builder.is_instance_of::() { - c.backend = "statevec".to_string(); + c.backend = Some("statevec".to_string()); c.stabmps_config = None; c.meas_sampling_method = None; } else { @@ -521,6 +812,20 @@ impl PySimNeoBuilder { Ok(c) } + /// Opt into automatic selection of unset components. + /// + /// Mirrors the Rust builder's `.auto()`: explicit-about-being-implicit. + /// If `.quantum()` was not called, the backend is selected automatically + /// (the stabilizer backend; circuits with inline channel operations route + /// to the density-matrix path instead, since the stabilizer cannot + /// execute arbitrary channels). The sampling strategy is never + /// auto-selected: `.sampling(monte_carlo(shots))` is always required. + fn auto(&self) -> Self { + let mut c = self.clone(); + c.auto = true; + c + } + /// Set the noise model. fn noise(&self, noise_builder: &PyNoiseModelBuilder) -> Self { let mut c = self.clone(); @@ -528,17 +833,57 @@ impl PySimNeoBuilder { c } + /// Set the sampling strategy (shots and workers live on the sampler). + /// + /// Accepts `monte_carlo(shots)` or `path_enumeration(max_measurements)`. + /// + /// Example: + /// sim_neo(tc).sampling(monte_carlo(1000).workers(4)).run() + /// sim_neo(tc).sampling(path_enumeration(2)).run() + fn sampling(&self, sampler: &Bound<'_, PyAny>) -> PyResult { + let mut c = self.clone(); + if sampler.is_instance_of::() { + let s: PyRef<'_, PyMonteCarloBuilder> = sampler.extract()?; + c.sampling = Some(PySampling::MonteCarlo { + shots: s.shots, + workers: s.workers, + }); + } else if sampler.is_instance_of::() { + let s: PyRef<'_, PyPathEnumerationBuilder> = sampler.extract()?; + c.sampling = Some(PySampling::PathEnumeration { + max_measurements: s.max_measurements, + }); + } else if sampler.is_instance_of::() { + let s: PyRef<'_, PySubsetSimulationBuilder> = sampler.extract()?; + c.sampling = Some(PySampling::SubsetSimulation { config: s.clone() }); + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "sampling() expects monte_carlo(shots), path_enumeration(max_measurements), \ + or subset_simulation(samples_per_level)", + )); + } + Ok(c) + } + /// Set number of shots. - fn shots(&self, n: usize) -> Self { + /// + /// Deprecated: use `.sampling(monte_carlo(shots))` instead. + fn shots(&self, py: Python<'_>, n: usize) -> PyResult { + PyErr::warn( + py, + &py.get_type::(), + c"sim_neo(...).shots(n) is deprecated; use .sampling(monte_carlo(n))", + 1, + )?; let mut c = self.clone(); - c.shots = n; - c + c.legacy_shots = Some(n); + Ok(c) } /// Set random seed. fn seed(&self, s: u64) -> Self { let mut c = self.clone(); - c.seed = s; + c.seed = Some(s); c } @@ -546,29 +891,44 @@ impl PySimNeoBuilder { /// /// All backends return `RawMeasurementResult` which supports: /// `result[shot]`, `result.get(shot, meas)`, `len(result)`, iteration. - fn run(&self) -> PyResult { + fn run(&self, py: Python<'_>) -> PyResult { if self.tick_circuit.has_channel_operations() { return self.run_inline_channel_circuit(); } - if self.backend == "meas_sampling" { + let backend = self.resolved_backend()?; + if backend == "meas_sampling" { return self.run_meas_sampling(); } + match self.resolved_sampling()? { + PySampling::PathEnumeration { max_measurements } => { + return self.run_path_enumeration(&backend, max_measurements); + } + PySampling::SubsetSimulation { config } => { + return self.run_subset_simulation(py, &backend, &config); + } + PySampling::MonteCarlo { .. } => {} + } + let (shots, workers) = self.resolved_monte_carlo("this backend")?; + 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); + .sampling(pecos_neo::tool::monte_carlo(shots).workers(workers)); + + if let Some(seed) = self.seed { + builder = builder.seed(seed); + } if let Some(n) = noise { builder = builder.noise(n); } - match self.backend.as_str() { + match backend.as_str() { "stabmps" => { let config = self.stabmps_config.clone().unwrap_or_default(); builder = builder.quantum(pecos_neo::tool::custom_backend_from_factory(config)); @@ -581,8 +941,7 @@ impl PySimNeoBuilder { } _ => { return Err(PyErr::new::(format!( - "Unknown backend: {}", - self.backend + "Unknown backend: {backend}" ))); } } @@ -590,7 +949,7 @@ impl PySimNeoBuilder { let mut sim = builder.build(); let results = sim.run(); - let mut all_shots = Vec::with_capacity(self.shots); + let mut all_shots = Vec::with_capacity(shots); for shot_outcomes in &results.outcomes { let meas: Vec = shot_outcomes.iter().map(|o| u8::from(o.outcome)).collect(); all_shots.push(meas); @@ -601,43 +960,334 @@ impl PySimNeoBuilder { } impl PySimNeoBuilder { - fn run_inline_channel_circuit(&self) -> PyResult { + /// Path enumeration: exhaustively enumerate measurement branches. + /// + /// Pre-validates with ValueError mirroring the Rust builder's + /// build-time checks, then runs through the Rust sim_neo builder. + fn run_path_enumeration( + &self, + backend: &str, + max_measurements: usize, + ) -> PyResult { + if backend != "stabilizer" { + return Err(pyo3::exceptions::PyValueError::new_err( + "Path enumeration currently supports only the stabilizer() backend \ + (or .auto()).", + )); + } if self.noise_config.is_some() { return Err(pyo3::exceptions::PyValueError::new_err( - "sim_neo received a TickCircuit with inline channel operations; do not also pass .noise()", + "Path enumeration enumerates measurement branches of the noiseless \ + circuit; remove .noise().", )); } + if max_measurements > 24 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Path enumeration covers 2^max_measurements paths; \ + max_measurements = {max_measurements} would enumerate more than 16M paths." + ))); + } + + let results = sim_neo(self.commands.clone()) + .quantum(pecos_neo::tool::sparse_stab()) + .sampling(pecos_neo::tool::path_enumeration(max_measurements)) + .build() + .run(); + + let rows: Vec> = results + .outcomes + .iter() + .map(|shot| shot.iter().map(|o| u8::from(o.outcome)).collect()) + .collect(); + let weights: Vec = results + .weights + .as_ref() + .map(|ws| { + ws.iter() + .map(pecos_neo::sampling::weight::SampleWeight::weight) + .collect() + }) + .unwrap_or_default(); + + Ok(PyRawMeasurementResult::from_rows_weighted(rows, weights)) + } + + /// Subset simulation with Python score/failure callables. + /// + /// Each callable receives the sample's measurement bits as `list[int]` + /// and is invoked once per sample on the calling thread. The first + /// Python exception raised by a callable aborts the run and propagates. + fn run_subset_simulation( + &self, + py: Python<'_>, + backend: &str, + config: &PySubsetSimulationBuilder, + ) -> PyResult { + use pecos_neo::outcome::MeasurementOutcomes; + use pecos_neo::sampling::subset::{SubsetConfig, SubsetSimulation}; + use std::sync::{Arc, Mutex}; + + if backend != "stabilizer" { + return Err(pyo3::exceptions::PyValueError::new_err( + "Subset simulation currently supports only the stabilizer() backend \ + (or .auto()).", + )); + } + let (Some(score), Some(failure)) = (&config.score, &config.failure) else { + return Err(pyo3::exceptions::PyValueError::new_err( + "Subset simulation requires both .score(..) and .failure(..) on the \ + subset_simulation(..) builder; neither has a sensible default.", + )); + }; + if config.max_levels > 1 && !config.allow_biased_multilevel { + return Err(pyo3::exceptions::PyValueError::new_err( + "subset_simulation with max_levels > 1 engages the multi-level estimator, \ + which is currently biased upward (the resample is unconditioned). Either \ + keep a single level (an unbiased direct-Monte-Carlo failure-fraction \ + estimate) or call .allow_biased_multilevel() to accept the documented bias.", + )); + } + + let noise = self + .noise_config + .as_ref() + .and_then(PyNoiseModelBuilder::build_noise); + + let num_qubits = self + .commands + .iter() + .flat_map(|cmd| cmd.qubits.iter()) + .map(|q| q.0) + .max() + .map_or(1, |max| max + 1); + + // Bridge Python callables into Fn closures. Errors cannot propagate + // through the closure signature, so capture the first one and check + // after the run. After an error, the closures stop calling Python + // and report every sample as a failure, which trips the algorithm's + // all-samples-failed termination at the end of the current level — + // bounding the wasted work without changing the library API. + let captured_err: Arc>> = Arc::new(Mutex::new(None)); + let bits_of = |outcomes: &MeasurementOutcomes| -> Vec { + outcomes.iter().map(|o| u8::from(o.outcome)).collect() + }; + let has_err = |slot: &Arc>>| { + slot.lock() + .expect("subset callable error slot poisoned") + .is_some() + }; + + let score_fn = score.clone_ref(py); + let score_err = Arc::clone(&captured_err); + let score_closure = move |outcomes: &MeasurementOutcomes| -> f64 { + if has_err(&score_err) { + return 0.0; + } + Python::attach(|py| { + match score_fn + .call1(py, (bits_of(outcomes),)) + .and_then(|v| v.extract::(py)) + { + Ok(v) => v, + Err(e) => { + score_err + .lock() + .expect("subset callable error slot poisoned") + .get_or_insert(e); + 0.0 + } + } + }) + }; + + let failure_fn = failure.clone_ref(py); + let failure_err = Arc::clone(&captured_err); + let failure_closure = move |outcomes: &MeasurementOutcomes| -> bool { + if has_err(&failure_err) { + // Steer the run to its all-failed termination condition. + return true; + } + Python::attach(|py| { + match failure_fn + .call1(py, (bits_of(outcomes),)) + .and_then(|v| v.extract::(py)) + { + Ok(v) => v, + Err(e) => { + failure_err + .lock() + .expect("subset callable error slot poisoned") + .get_or_insert(e); + true + } + } + }) + }; + + let subset_config = SubsetConfig { + samples_per_level: config.samples_per_level, + threshold_fraction: config.threshold_fraction, + max_levels: config.max_levels, + min_conditional_prob: config.min_conditional_prob, + seed: self.seed, + }; + + let result = SubsetSimulation::new( + self.commands.clone(), + num_qubits, + score_closure, + failure_closure, + ) + .with_noise_builder(move || noise.clone()) + .with_config(subset_config) + .run(); + + if let Some(err) = captured_err + .lock() + .expect("subset callable error slot poisoned") + .take() + { + return Err(err); + } + + let subset = Py::new(py, PySubsetResult { inner: result })?; + Ok(PyRawMeasurementResult::from_subset(subset)) + } - 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", + /// Resolve the sampling strategy, mirroring the Rust builder's rules + /// and error messages. + fn resolved_sampling(&self) -> PyResult { + match (&self.sampling, self.legacy_shots) { + (Some(sampling), None) => Ok(sampling.clone()), + (Some(_), Some(_)) => Err(pyo3::exceptions::PyValueError::new_err( + "Conflicting sampling configuration: deprecated .shots() cannot be combined \ + with .sampling(). Set shots on the sampler builder, e.g. \ + .sampling(monte_carlo(1000)).", )), - "meas_sampling" => Err(pyo3::exceptions::PyValueError::new_err( - "meas_sampling backend builds its own measurement model and does not consume inline channel operations", + (None, Some(shots)) => Ok(PySampling::MonteCarlo { shots, workers: 1 }), + (None, None) => Err(pyo3::exceptions::PyValueError::new_err( + "No sampling strategy set. Use .sampling(monte_carlo(shots)).", )), - other => Err(pyo3::exceptions::PyValueError::new_err(format!( - "Unknown backend: {other}" - ))), } } - fn run_inline_channel_density_matrix(&self) -> PyResult { + /// Resolve to (shots, workers) for execution paths that only support + /// Monte Carlo sampling. + fn resolved_monte_carlo(&self, path_name: &str) -> PyResult<(usize, usize)> { + match self.resolved_sampling()? { + PySampling::MonteCarlo { shots, workers } => Ok((shots, workers)), + PySampling::PathEnumeration { .. } => { + Err(pyo3::exceptions::PyValueError::new_err(format!( + "{path_name} does not support path enumeration; use \ + .sampling(monte_carlo(shots)) instead." + ))) + } + PySampling::SubsetSimulation { .. } => { + Err(pyo3::exceptions::PyValueError::new_err(format!( + "{path_name} does not support subset simulation; use \ + .sampling(monte_carlo(shots)) instead." + ))) + } + } + } + + /// Resolve the quantum backend, mirroring the Rust builder's rules: + /// explicit `.quantum()` wins; `.auto()` opts into automatic selection; + /// otherwise fail fast. Auto selects the stabilizer backend, except for + /// circuits with inline channel operations, which route to the + /// density-matrix path (the stabilizer cannot execute arbitrary + /// channels). + fn resolved_backend(&self) -> PyResult { + if let Some(backend) = &self.backend { + return Ok(backend.clone()); + } + if self.auto { + let auto_backend = if self.tick_circuit.has_channel_operations() { + "statevec" + } else { + "stabilizer" + }; + return Ok(auto_backend.to_string()); + } + Err(pyo3::exceptions::PyValueError::new_err( + "No quantum backend set. Use .quantum(stabilizer()) or .quantum(statevec()), \ + or call .auto() to let sim_neo choose.", + )) + } + + /// Concrete seed for execution paths that require one. Unset seed means + /// nondeterministic (mirroring the Rust builder), so draw fresh entropy. + fn resolved_seed_u64(&self) -> u64 { + use std::hash::{BuildHasher, Hasher}; + self.seed.unwrap_or_else(|| { + std::collections::hash_map::RandomState::new() + .build_hasher() + .finish() + }) + } + + fn run_inline_channel_circuit(&self) -> PyResult { + if self.noise_config.is_some() { + return Err(pyo3::exceptions::PyValueError::new_err( + "sim_neo received a TickCircuit with inline channel operations; do not also pass .noise()", + )); + } + let backend = self.resolved_backend()?; + match backend.as_str() { + "statevec" | "stabilizer" => {} + "stabmps" => { + return Err(pyo3::exceptions::PyValueError::new_err( + "stab_mps backend does not support inline channel operations; use statevec() for density-matrix execution", + )); + } + "meas_sampling" => { + return Err(pyo3::exceptions::PyValueError::new_err( + "meas_sampling backend builds its own measurement model and does not consume inline channel operations", + )); + } + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unknown backend: {other}" + ))); + } + } + let (shots, workers) = self.resolved_monte_carlo("inline-channel execution")?; + if workers > 1 { + return Err(pyo3::exceptions::PyValueError::new_err( + "inline-channel execution does not support parallel workers; use monte_carlo(shots) without .workers()", + )); + } + let seed = self.resolved_seed_u64(); + + match backend.as_str() { + "statevec" => self.run_inline_channel_density_matrix(shots, seed), + _ => self.run_inline_pauli_channel_stabilizer(shots, seed), + } + } + + fn run_inline_channel_density_matrix( + &self, + shots: usize, + seed: u64, + ) -> PyResult { let rows = pecos_neo::inline_channel::run_inline_channels_density_matrix( &self.tick_circuit, - self.shots, - self.seed, + shots, + seed, ) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; Ok(PyRawMeasurementResult::from_rows(rows)) } - fn run_inline_pauli_channel_stabilizer(&self) -> PyResult { + fn run_inline_pauli_channel_stabilizer( + &self, + shots: usize, + seed: u64, + ) -> PyResult { let rows = pecos_neo::inline_channel::run_inline_pauli_channels_stabilizer( &self.tick_circuit, - self.shots, - self.seed, + shots, + seed, ) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; Ok(PyRawMeasurementResult::from_rows(rows)) @@ -645,6 +1295,12 @@ impl PySimNeoBuilder { /// DEM sampling backend: dispatches to stochastic or coherent path based on method. fn run_meas_sampling(&self) -> PyResult { + let (_, workers) = self.resolved_monte_carlo("meas_sampling")?; + if workers > 1 { + return Err(pyo3::exceptions::PyValueError::new_err( + "meas_sampling does its own batch sampling and does not support parallel workers; use monte_carlo(shots) without .workers()", + )); + } let noise_config = self.noise_config.as_ref().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err("DEM sampling requires .noise() to be set") })?; @@ -704,7 +1360,8 @@ impl PySimNeoBuilder { .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; let plan = RawMeasurementPlan::new(&history, mechanisms); - let result = plan.sample(self.shots, self.seed); + let (shots, _) = self.resolved_monte_carlo("meas_sampling")?; + let result = plan.sample(shots, self.resolved_seed_u64()); Ok(PyRawMeasurementResult::from_columnar(result)) } @@ -781,13 +1438,14 @@ impl PySimNeoBuilder { let gates = commands_to_gates(&self.commands); let generator = select_generator(method, noise_config.idle_rz_angle); + let (shots, _) = self.resolved_monte_carlo("meas_sampling")?; let result = run_dem_simulation( &gates, &noise, &meta, generator.as_ref(), - self.shots, - self.seed, + shots, + self.resolved_seed_u64(), ); Ok(result.measurements) } @@ -825,11 +1483,16 @@ fn commands_to_gates(commands: &pecos_neo::command::CommandQueue) -> Vec) -> PyResult Ok(PySimNeoBuilder { commands, tick_circuit: std::sync::Arc::new(tc), - shots: 1, - seed: 42, + sampling: None, + legacy_shots: None, + seed: None, + auto: false, noise_config: None, - backend: "statevec".to_string(), + backend: None, stabmps_config: None, meas_sampling_method: None, }) @@ -925,6 +1590,7 @@ fn build_rust_tick_circuit_from_gates( "QAlloc" | "PZ" | "Prep" => { pz_qubits.extend(qubit_ids); } + "TrackedPauli" | "TrackedPauliMeta" => {} _ => { let core_gate = build_gate_from_python(gate, &gate_name, &qubit_ids)?; other_gates.push(core_gate); @@ -1295,7 +1961,11 @@ fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { cb = cb.tdg(&qubits); } - "MZ" => { + "MZ" | "Measure" | "MeasureFree" => { + // Z-basis measurement; ``MeasureFree`` additionally frees the + // qubit, which is a no-op for the fixed-width simulator. Kept + // consistent with build_rust_tick_circuit_from_gates and the + // surface DEM path, which treat all three as Z measurements. cb = cb.mz(&qubits); } "RX" => { @@ -1350,8 +2020,8 @@ fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { - // Identity/Idle gates: skip (no-op for simulation) + "I" | "Idle" | "TrackedPauli" | "TrackedPauliMeta" => { + // Identity/Idle and tracked-Pauli metadata gates: skip (no-op for simulation) } _ => { return Err(PyErr::new::(format!( diff --git a/python/pecos-rslib-llvm/Cargo.toml b/python/pecos-rslib-llvm/Cargo.toml index 8c230b0a4..3a81db1af 100644 --- a/python/pecos-rslib-llvm/Cargo.toml +++ b/python/pecos-rslib-llvm/Cargo.toml @@ -26,7 +26,7 @@ extension-module = [ [dependencies] pecos-llvm.workspace = true -inkwell = { workspace = true, features = ["llvm14-0"] } +inkwell.workspace = true pyo3.workspace = true regex.workspace = true tempfile.workspace = true diff --git a/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py b/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py new file mode 100644 index 000000000..ca325a77e --- /dev/null +++ b/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py @@ -0,0 +1,56 @@ +"""Python package wrapper for the native ``pecos_rslib_llvm`` extension.""" + +from __future__ import annotations + +import os +from pathlib import Path + +_DLL_DIRECTORY_HANDLES = [] + + +def _add_dll_directory(path: Path) -> None: + if os.name != "nt" or not hasattr(os, "add_dll_directory") or not path.is_dir(): + return + + try: + _DLL_DIRECTORY_HANDLES.append(os.add_dll_directory(str(path))) + except OSError: + pass + + +def _add_windows_llvm_dll_directories() -> None: + if os.name != "nt": + return + + seen: set[str] = set() + candidates: list[Path] = [] + + for env_name in ("PECOS_LLVM", "LLVM_SYS_211_PREFIX"): + if raw_path := os.environ.get(env_name): + prefix = Path(raw_path) + candidates.extend((prefix / "bin", prefix)) + + home = Path.home() + candidates.extend( + ( + home / ".pecos" / "deps" / "llvm-21.1" / "Library" / "bin", + home / ".pecos" / "deps" / "llvm-21.1" / "bin", + ) + ) + + for candidate in candidates: + key = os.path.normcase(os.path.normpath(str(candidate))) + if key in seen: + continue + seen.add(key) + _add_dll_directory(candidate) + + +_add_windows_llvm_dll_directories() + +from . import pecos_rslib_llvm as _native # noqa: E402 +from .pecos_rslib_llvm import * # noqa: E402,F403 + +__doc__ = _native.__doc__ +if hasattr(_native, "__all__"): + __all__ = _native.__all__ diff --git a/python/pecos-rslib-llvm/src/llvm_bindings.rs b/python/pecos-rslib-llvm/src/llvm_bindings.rs index 4cf3cefbf..fcc3877c7 100644 --- a/python/pecos-rslib-llvm/src/llvm_bindings.rs +++ b/python/pecos-rslib-llvm/src/llvm_bindings.rs @@ -290,6 +290,8 @@ impl PyLLVMModule { function: ll_function.get(), // Get the underlying FunctionValue context_ptr: self.context_ptr, module_id: self.module_ptr as usize, + ret_pointee_type: func_type.ret_pointee_type, + param_pointee_types: func_type.param_pointee_types.clone(), } } @@ -309,6 +311,7 @@ impl PyLLVMModule { let global = module.add_global(name, ll_type, init_val); PyGlobalVariable { global, + value_type: ll_type, context_ptr: self.context_ptr, } } @@ -377,7 +380,12 @@ impl PyModuleContext { is_var_arg: Option, ) -> PyFunctionType { let context = unsafe { &*self.context_ptr }; + let ret_pointee_type = return_type.pointer_pointee_type(); let ret_ty = return_type.to_ll_type(context); + let param_pointee_types: Vec<_> = param_types + .iter() + .map(PyAnyType::pointer_pointee_type) + .collect(); let param_tys: Vec<_> = param_types .into_iter() .map(|pt| pt.to_ll_type(context)) @@ -385,7 +393,9 @@ impl PyModuleContext { PyFunctionType { ret_type: ret_ty, + ret_pointee_type, param_types: param_tys, + param_pointee_types, var_args: is_var_arg.unwrap_or(false), context_ptr: self.context_ptr, } @@ -428,6 +438,17 @@ impl PyAnyType { PyAnyType::Array(t) => t.ll_type, } } + + fn pointer_pointee_type(&self) -> Option> { + match self { + PyAnyType::Pointer(t) => t.pointee_type, + PyAnyType::Int(_) + | PyAnyType::Double(_) + | PyAnyType::Void(_) + | PyAnyType::Struct(_) + | PyAnyType::Array(_) => None, + } + } } /// Type equality by LLVM `LLVMTypeRef` identity within one `Context` @@ -462,6 +483,38 @@ fn lltype_hash(a: LLType<'static>) -> u64 { std::hash::Hasher::finish(&h) } +fn pointer_type_richcmp( + py: Python<'_>, + this: &PyPointerType, + other: &Bound<'_, PyAny>, + op: CompareOp, +) -> Py { + match op { + CompareOp::Eq | CompareOp::Ne => { + let eq = other.extract::().is_ok_and(|o| match o { + PyAnyType::Pointer(p) => { + this.ll_type == p.ll_type && this.pointee_type == p.pointee_type + } + _ => this.ll_type == o.ll_type(), + }); + let val = if matches!(op, CompareOp::Eq) { eq } else { !eq }; + val.into_pyobject(py) + .expect("bool -> PyBool is infallible") + .to_owned() + .into_any() + .unbind() + } + _ => py.NotImplemented(), + } +} + +fn pointer_type_hash(pointer: PyPointerType) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&pointer.ll_type, &mut h); + std::hash::Hash::hash(&pointer.pointee_type, &mut h); + std::hash::Hasher::finish(&h) +} + /// Python wrapper for struct types #[pyclass(name = "StructType", from_py_object)] #[derive(Copy, Clone)] @@ -482,6 +535,7 @@ impl PyStructType { let ptr_type = ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(ll_type), context_ptr: self.context_ptr, } } @@ -492,6 +546,7 @@ impl PyStructType { #[derive(Copy, Clone)] pub struct PyPointerType { ll_type: LLType<'static>, + pointee_type: Option>, context_ptr: *mut Context, } @@ -501,10 +556,10 @@ unsafe impl Sync for PyPointerType {} #[pymethods] impl PyPointerType { fn __richcmp__(&self, py: Python<'_>, other: &Bound<'_, PyAny>, op: CompareOp) -> Py { - lltype_richcmp(py, self.ll_type, other, op) + pointer_type_richcmp(py, self, other, op) } fn __hash__(&self) -> u64 { - lltype_hash(self.ll_type) + pointer_type_hash(*self) } fn as_pointer(&self) -> PyPointerType { @@ -512,6 +567,7 @@ impl PyPointerType { let ptr_type = self.ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(self.ll_type), context_ptr: self.context_ptr, } } @@ -542,6 +598,7 @@ impl PyIntType { let ptr_type = self.ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(self.ll_type), context_ptr: self.context_ptr, } } @@ -581,6 +638,7 @@ impl PyDoubleType { let ptr_type = self.ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(self.ll_type), context_ptr: self.context_ptr, } } @@ -642,6 +700,7 @@ impl PyArrayType { let ptr_type = self.ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(self.ll_type), context_ptr: self.context_ptr, } } @@ -902,9 +961,18 @@ impl PyIRBuilder { let result = builder .call(function.function, &arg_values, name) .map_err(|e| PyRuntimeError::new_err(format!("call failed: {e}")))?; - Ok(result.map(|value| PyLLValue { - value, - context_ptr: self.context_ptr, + Ok(result.map(|value| { + let value = match value { + LLValue::Pointer(pointer) => LLValue::Pointer(LLPointerValue::new( + pointer.value(), + function.ret_pointee_type, + )), + LLValue::Int(_) | LLValue::Float(_) | LLValue::Array(_) => value, + }; + PyLLValue { + value, + context_ptr: self.context_ptr, + } })) } @@ -945,7 +1013,7 @@ impl PyIRBuilder { }) } - /// Load from a pointer (`load`; LLVM-14 typed-pointer pointee). + /// Load from a pointer (`load`; uses the tracked pointee type for opaque pointers). #[pyo3(signature = (ptr, name=""))] fn load(&mut self, ptr: PyLLValue, name: &str) -> PyResult { let builder = unsafe { &mut *self.builder_ptr }; @@ -1275,6 +1343,8 @@ pub struct PyFunction { context_ptr: *mut Context, /// Module ID for comment tracking module_id: usize, + ret_pointee_type: Option>, + param_pointee_types: Vec>>, } unsafe impl Send for PyFunction {} @@ -1300,11 +1370,17 @@ impl PyFunction { // Get function parameters and wrap in PyLLValue self.function .get_param_iter() - .map(|param| { + .enumerate() + .map(|(index, param)| { // Convert BasicValueEnum to LLValue - only supporting types in LLValue enum let value = match param { inkwell::values::BasicValueEnum::IntValue(v) => LLValue::Int(v), - inkwell::values::BasicValueEnum::PointerValue(v) => LLValue::Pointer(v), + inkwell::values::BasicValueEnum::PointerValue(v) => { + LLValue::Pointer(LLPointerValue::new( + v, + self.param_pointee_types.get(index).copied().flatten(), + )) + } inkwell::values::BasicValueEnum::ArrayValue(v) => LLValue::Array(v), _ => panic!("Unsupported parameter type (float values not in LLValue enum)"), }; @@ -1335,7 +1411,9 @@ unsafe impl Sync for PyBasicBlock {} #[derive(Clone)] pub struct PyFunctionType { ret_type: LLType<'static>, + ret_pointee_type: Option>, param_types: Vec>, + param_pointee_types: Vec>>, var_args: bool, #[allow(dead_code)] context_ptr: *mut Context, @@ -1363,7 +1441,12 @@ impl PyFunctionType { }; let context = unsafe { &*context_ptr }; + let ret_pointee_type = return_type.pointer_pointee_type(); let ret_ty = return_type.to_ll_type(context); + let param_pointee_types: Vec<_> = param_types + .iter() + .map(PyAnyType::pointer_pointee_type) + .collect(); let param_tys: Vec<_> = param_types .into_iter() .map(|pt| pt.to_ll_type(context)) @@ -1371,7 +1454,9 @@ impl PyFunctionType { Self { ret_type: ret_ty, + ret_pointee_type, param_types: param_tys, + param_pointee_types, var_args, context_ptr, } @@ -1402,7 +1487,9 @@ impl PyLLValue { }; // Get the pointer type from PyPointerType - let LLType::Pointer(target_ptr_type) = ptr_type.ll_type else { + let (LLType::Pointer(target_ptr_type) | LLType::TypedPointer(target_ptr_type, _)) = + ptr_type.ll_type + else { return Err(PyRuntimeError::new_err("Target must be a pointer type")); }; @@ -1410,7 +1497,7 @@ impl PyLLValue { let ptr_val = int_val.const_to_pointer(target_ptr_type); Ok(Self { - value: LLValue::Pointer(ptr_val), + value: LLValue::Pointer(LLPointerValue::new(ptr_val, ptr_type.pointee_type)), context_ptr: self.context_ptr, }) } @@ -1428,10 +1515,20 @@ impl PyLLValue { ll_type: LLType::Float(v.get_type()), context_ptr: self.context_ptr, }), - LLValue::Pointer(v) => PyAnyType::Pointer(PyPointerType { - ll_type: LLType::Pointer(v.get_type()), - context_ptr: self.context_ptr, - }), + LLValue::Pointer(v) => { + let pointee_type = v.pointee_type(); + let ll_type = pointee_type + .and_then(|pointee_type| { + LLType::typed_pointer(v.value().get_type(), pointee_type) + }) + .unwrap_or_else(|| LLType::Pointer(v.value().get_type())); + + PyAnyType::Pointer(PyPointerType { + ll_type, + pointee_type, + context_ptr: self.context_ptr, + }) + } LLValue::Array(v) => PyAnyType::Array(PyArrayType { ll_type: LLType::Array(v.get_type()), context_ptr: self.context_ptr, @@ -1448,6 +1545,7 @@ impl PyLLValue { #[pyclass(name = "GlobalVariable")] pub struct PyGlobalVariable { global: inkwell::values::GlobalValue<'static>, + value_type: LLType<'static>, context_ptr: *mut Context, } @@ -1467,6 +1565,7 @@ impl PyGlobalVariable { let global = module_ref.add_global(name, ll_type, None); Self { global, + value_type: ll_type, context_ptr: module.context_ptr, } } @@ -1477,7 +1576,7 @@ impl PyGlobalVariable { match &value.value { LLValue::Int(v) => self.global.set_initializer(v), LLValue::Float(v) => self.global.set_initializer(v), - LLValue::Pointer(v) => self.global.set_initializer(v), + LLValue::Pointer(v) => self.global.set_initializer(&v.value()), LLValue::Array(v) => self.global.set_initializer(v), } } @@ -1516,11 +1615,20 @@ impl PyGlobalVariable { .collect(); let int_indices = int_indices?; + let basic_ty = self + .value_type + .to_basic_metadata_type() + .ok_or_else(|| PyRuntimeError::new_err("Cannot GEP into a void global"))?; // Use const_gep for global variables - let gep_val = unsafe { self.global.as_pointer_value().const_gep(&int_indices) }; + let gep_val = unsafe { + self.global + .as_pointer_value() + .const_gep(basic_ty, &int_indices) + }; + let pointee_type = gep_result_pointee_type(self.value_type, &int_indices); Ok(PyLLValue { - value: LLValue::Pointer(gep_val), + value: LLValue::Pointer(LLPointerValue::new(gep_val, pointee_type)), context_ptr: self.context_ptr, }) } @@ -1528,7 +1636,10 @@ impl PyGlobalVariable { /// Get the pointer value of this global fn as_pointer_value(&self) -> PyLLValue { PyLLValue { - value: LLValue::Pointer(self.global.as_pointer_value()), + value: LLValue::Pointer(LLPointerValue::new( + self.global.as_pointer_value(), + Some(self.value_type), + )), context_ptr: self.context_ptr, } } @@ -1827,9 +1938,12 @@ impl PyModuleRef { }) })?; - // Write module to bitcode + // `MemoryBuffer::as_slice()` includes LLVM's trailing C-string NUL; + // that byte is not part of the bitcode stream and strict readers + // reject it. let bitcode_buffer = module.write_bitcode_to_memory(); - Ok(bitcode_buffer.as_slice().to_vec()) + let bitcode = bitcode_buffer.as_slice(); + Ok(bitcode[..bitcode.len().saturating_sub(1)].to_vec()) } } diff --git a/python/pecos-rslib/Cargo.toml b/python/pecos-rslib/Cargo.toml index d0db547a2..c31890e3b 100644 --- a/python/pecos-rslib/Cargo.toml +++ b/python/pecos-rslib/Cargo.toml @@ -51,6 +51,8 @@ pecos-num.workspace = true pecos-random.workspace = true pecos-simulators.workspace = true pecos-engines.workspace = true +# Unified sim() facade with neo-stack routing (strangler-fig: Python .stack("neo")) +pecos = { workspace = true, default-features = false, features = ["neo", "hugr"] } pecos-programs.workspace = true # Program formats diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index afab181c1..f5a792e9f 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1397,6 +1397,8 @@ class llvm: # Tick Circuit # ============================================================================= +PHYSICAL_DURATION_META_KEY: str + class GateType: """Gate type marker.""" @@ -1442,6 +1444,8 @@ class GateType: QAlloc: GateType QFree: GateType TrackedPauliMeta: GateType + MeasCrosstalkGlobalPayload: GateType + MeasCrosstalkLocalPayload: GateType Custom: GateType @property @@ -1659,6 +1663,12 @@ class TickCircuit: def set_gate_meta(self, tick_idx: int, gate_idx: int, key: str, value: Any) -> None: ... def get_gate_meta(self, tick_idx: int, gate_idx: int, key: str) -> Any | None: ... def lower_clifford_rotations(self) -> None: ... + def remove_identity(self) -> None: ... + def cancel_inverses(self) -> None: ... + def merge_adjacent_rotations(self) -> None: ... + def peephole_optimize(self) -> None: ... + def absorb_basis_gates(self) -> None: ... + def simplify_single_qubit_clifford_chains(self) -> None: ... def assign_missing_meas_ids(self) -> None: ... def insert_idle_after_two_qubit_gates(self, duration: float = 1.0) -> None: ... def fill_idle_gates(self) -> None: ... diff --git a/python/pecos-rslib/pecos_rslib/__init__.py b/python/pecos-rslib/pecos_rslib/__init__.py new file mode 100644 index 000000000..9095b6cab --- /dev/null +++ b/python/pecos-rslib/pecos_rslib/__init__.py @@ -0,0 +1,56 @@ +"""Python package wrapper for the native ``pecos_rslib`` extension.""" + +from __future__ import annotations + +import os +from pathlib import Path + +_DLL_DIRECTORY_HANDLES = [] + + +def _add_dll_directory(path: Path) -> None: + if os.name != "nt" or not hasattr(os, "add_dll_directory") or not path.is_dir(): + return + + try: + _DLL_DIRECTORY_HANDLES.append(os.add_dll_directory(str(path))) + except OSError: + pass + + +def _add_windows_llvm_dll_directories() -> None: + if os.name != "nt": + return + + seen: set[str] = set() + candidates: list[Path] = [] + + for env_name in ("PECOS_LLVM", "LLVM_SYS_211_PREFIX"): + if raw_path := os.environ.get(env_name): + prefix = Path(raw_path) + candidates.extend((prefix / "bin", prefix)) + + home = Path.home() + candidates.extend( + ( + home / ".pecos" / "deps" / "llvm-21.1" / "Library" / "bin", + home / ".pecos" / "deps" / "llvm-21.1" / "bin", + ) + ) + + for candidate in candidates: + key = os.path.normcase(os.path.normpath(str(candidate))) + if key in seen: + continue + seen.add(key) + _add_dll_directory(candidate) + + +_add_windows_llvm_dll_directories() + +from . import pecos_rslib as _native # noqa: E402 +from .pecos_rslib import * # noqa: E402,F403 + +__doc__ = _native.__doc__ +if hasattr(_native, "__all__"): + __all__ = _native.__all__ diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index f033a6b22..50f110181 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -26,7 +26,8 @@ use crate::dtypes::AngleParam; use crate::gate_registry_bindings::PyGateRegistry; use pecos_core::{Angle64, ChannelExpr, GateQubits, GateSignature, Pauli, TimeUnits}; use pecos_quantum::{ - Attribute, DagCircuit, Gate, GateType, QubitId, Tick, TickCircuit, TickGateError, + Attribute, DagCircuit, Gate, GateType, PHYSICAL_DURATION_META_KEY, QubitId, Tick, TickCircuit, + TickGateError, }; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList}; @@ -613,6 +614,22 @@ impl PyGateType { } } + #[classattr] + #[pyo3(name = "MeasCrosstalkGlobalPayload")] + fn meas_crosstalk_global_payload() -> Self { + Self { + inner: GateType::MeasCrosstalkGlobalPayload, + } + } + + #[classattr] + #[pyo3(name = "MeasCrosstalkLocalPayload")] + fn meas_crosstalk_local_payload() -> Self { + Self { + inner: GateType::MeasCrosstalkLocalPayload, + } + } + #[classattr] #[pyo3(name = "Custom")] fn custom() -> Self { @@ -2655,6 +2672,60 @@ impl PyTickCircuit { SimplifyRotations.apply_tick(&mut self.inner); } + /// Remove identity gates and zero-angle rotations. + /// + /// Modifies the circuit in place. + fn remove_identity(&mut self) { + use pecos_quantum::pass::{CircuitPass, RemoveIdentity}; + RemoveIdentity.apply_tick(&mut self.inner); + } + + /// Cancel adjacent inverse gate pairs. + /// + /// This removes adjacent inverse pairs such as H-H, SX-SXdg, and SZZ-SZZdg + /// when they act on the same qubits with no intervening operation on those + /// qubits. Modifies the circuit in place. + fn cancel_inverses(&mut self) { + use pecos_quantum::pass::{CancelInverses, CircuitPass}; + CancelInverses.apply_tick(&mut self.inner); + } + + /// Merge adjacent same-axis rotation gates. + /// + /// Consecutive rotations such as RZ(a) followed by RZ(b) on the same qubit + /// become one RZ(a+b). Run lower_clifford_rotations() afterwards when + /// special-angle rotations should become named Clifford gates. + /// Modifies the circuit in place. + fn merge_adjacent_rotations(&mut self) { + use pecos_quantum::pass::{CircuitPass, MergeAdjacentRotations}; + MergeAdjacentRotations.apply_tick(&mut self.inner); + } + + /// Run PECOS's local peephole optimizer. + /// + /// Currently recognizes small Clifford patterns such as H-conjugated + /// two-qubit gates. Modifies the circuit in place. + fn peephole_optimize(&mut self) { + use pecos_quantum::pass::{CircuitPass, PeepholeOptimize}; + PeepholeOptimize.apply_tick(&mut self.inner); + } + + /// Absorb redundant Z-diagonal gates next to Z preparations/measurements. + /// + /// Modifies the circuit in place. + fn absorb_basis_gates(&mut self) { + use pecos_quantum::pass::{AbsorbBasisGates, CircuitPass}; + AbsorbBasisGates.apply_tick(&mut self.inner); + } + + /// Simplify adjacent single-qubit Clifford chains. + /// + /// Modifies the circuit in place. + fn simplify_single_qubit_clifford_chains(&mut self) { + use pecos_quantum::pass::{CircuitPass, SimplifySingleQubitCliffordChains}; + SimplifySingleQubitCliffordChains.apply_tick(&mut self.inner); + } + /// Assign MeasId to measurement gates that don't have them. /// /// Use on circuits from external sources (QIS trace, Stim import) @@ -3480,11 +3551,18 @@ impl PyTickHandle { ))); } - // Determine if we need to broadcast (e.g. single-qubit gate on multiple qubits) - let needs_broadcast = - arity > 0 && qubits.len() > arity && qubits.len().is_multiple_of(arity); + let variable_arity_payload = gate_type.is_crosstalk_payload() + || matches!(gate_type, GateType::Channel | GateType::TrackedPauliMeta); - if arity > 0 && qubits.len() != arity && !needs_broadcast { + // Determine if we need to broadcast (e.g. single-qubit gate on multiple qubits). + // Payload/meta gates carry their qubit list as data and must remain a single gate. + let needs_broadcast = !variable_arity_payload + && arity > 0 + && qubits.len() > arity + && qubits.len().is_multiple_of(arity); + + if !variable_arity_payload && arity > 0 && qubits.len() != arity && !needs_broadcast + { return Err(pyo3::exceptions::PyValueError::new_err(format!( "Gate '{name}' requires {} qubit(s), got {} (not a valid multiple)", arity, @@ -3800,6 +3878,7 @@ pub fn register_quantum_circuit_types(parent_module: &Bound<'_, PyModule>) -> Py parent_module.add_class::()?; parent_module.add_class::()?; parent_module.add_class::()?; + parent_module.add("PHYSICAL_DURATION_META_KEY", PHYSICAL_DURATION_META_KEY)?; // Add exceptions parent_module.add( diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index bf9d27879..ac9f51ac5 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -321,7 +321,7 @@ impl PyCheckMatrix { /// H = [[1, 1, 0], [0, 1, 1]] /// decoder = PyMatchingDecoder.from_check_matrix(CheckMatrix.from_dense(H)) /// -/// # From Stim detector error model +/// # From detector error model /// decoder = PyMatchingDecoder.from_dem(dem_string) /// /// # Manual graph construction (like PyMatching's add_edge) @@ -420,7 +420,7 @@ impl PyPyMatchingDecoder { .map_err(|e| PyErr::new::(e.to_string())) } - /// Create decoder from a Stim Detector Error Model. + /// Create decoder from a Detector Error Model. /// /// This mirrors `PyMatching`'s `Matching.from_detector_error_model()`. /// @@ -441,6 +441,18 @@ impl PyPyMatchingDecoder { .map_err(|e| PyErr::new::(e.to_string())) } + /// Create decoder from a Detector Error Model with correlation support. + /// + /// When enabled, PyMatching preserves DEM decomposition correlations while + /// constructing and decoding the matching graph. + #[staticmethod] + #[pyo3(signature = (dem, enable_correlations=true))] + fn from_dem_with_correlations(dem: &str, enable_correlations: bool) -> PyResult { + RustPyMatchingDecoder::from_dem_with_correlations(dem, enable_correlations) + .map(|inner| Self { inner }) + .map_err(|e| PyErr::new::(e.to_string())) + } + /// Add an edge between two detector nodes. /// /// This mirrors `PyMatching`'s `Matching.add_edge()`. diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index f18ef6271..08226789a 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -24,6 +24,7 @@ type RustStateVectorEngineBuilder = StateVectorEngineBuilder; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; // Import existing shot result types @@ -79,10 +80,13 @@ impl PyQasmEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, foreign_object: None, + stack: None, + classical_override: false, }), }) } @@ -93,6 +97,7 @@ impl PyQasmEngineBuilder { #[derive(Clone)] pub struct PyQisEngineBuilder { pub(crate) inner: RustQisEngineBuilder, + runtime_configured: bool, } #[pymethods] @@ -101,6 +106,7 @@ impl PyQisEngineBuilder { fn new() -> Self { Self { inner: pecos_qis::qis_engine(), + runtime_configured: false, } } @@ -139,14 +145,42 @@ impl PyQisEngineBuilder { Ok(self.clone()) } - /// Use Selene simple runtime - fn selene_runtime(&mut self) -> PyResult { - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { + /// Use a Selene runtime built into the current PECOS/Cargo target. + #[pyo3(signature = (runtime_name = None))] + fn selene_runtime(&mut self, runtime_name: Option<&str>) -> PyResult { + let runtime = match runtime_name { + None | Some("selene_simple_runtime") => pecos_qis::selene_simple_runtime(), + Some(name) => pecos_qis::selene_runtime_auto(name), + } + .map_err(|e| { PyErr::new::(format!( "Failed to load Selene runtime: {e}" )) })?; self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; + Ok(self.clone()) + } + + /// Use a generic Selene runtime plugin by its shared library and plugin arguments. + #[pyo3(signature = (library_file, init_args = None, library_search_dirs = None))] + fn selene_runtime_plugin( + &mut self, + library_file: &str, + init_args: Option>, + library_search_dirs: Option>, + ) -> PyResult { + let runtime = pecos_qis::SeleneRuntime::with_plugin_config( + library_file, + init_args.unwrap_or_default(), + library_search_dirs + .unwrap_or_default() + .into_iter() + .map(PathBuf::from) + .collect(), + ); + self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; Ok(self.clone()) } @@ -163,14 +197,18 @@ impl PyQisEngineBuilder { .clone() .interface(pecos_qis::helios_interface_builder()); - // Always set Selene runtime to work with Helios interface - log::debug!("Setting Selene runtime for Helios interface"); - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { - PyErr::new::(format!( - "Failed to load Selene runtime: {e}" - )) - })?; - self.inner = self.inner.clone().runtime(runtime); + if !self.runtime_configured { + log::debug!( + "No runtime configured; setting default Selene runtime for Helios interface" + ); + let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { + PyErr::new::(format!( + "Failed to load Selene runtime: {e}" + )) + })?; + self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; + } log::debug!("Helios interface and Selene runtime configured"); Ok(self.clone()) @@ -190,6 +228,7 @@ impl PyQisEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -238,6 +277,7 @@ impl PyPhirJsonEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -254,10 +294,16 @@ pub struct PyQasmSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, pub(crate) foreign_object: Option>, + pub(crate) stack: Option, + /// True once `.classical()` has supplied an explicit engine builder. + /// The neo route rejects it (the facade contract has no classical + /// override on neo), matching the Rust `sim().stack(Neo)` behavior. + pub(crate) classical_override: bool, } /// Python wrapper for built QASM simulation @@ -347,6 +393,7 @@ pub struct PyQisControlSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, @@ -416,6 +463,7 @@ pub struct PyPhirJsonSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, @@ -454,6 +502,7 @@ impl PyPhirEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -467,6 +516,7 @@ pub struct PyPhirSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, @@ -567,12 +617,14 @@ impl PyHugrEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, foreign_object: None, keep_intermediate_files: false, hugr_bytes: None, + stack: None, }), }) } @@ -583,12 +635,14 @@ pub struct PyHugrSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, pub(crate) foreign_object: Option>, pub(crate) keep_intermediate_files: bool, pub(crate) hugr_bytes: Option>, + pub(crate) stack: Option, } /// Python wrapper for built HUGR simulation @@ -745,19 +799,26 @@ pub fn qasm_engine() -> PyQasmEngineBuilder { pub fn qis_engine() -> PyQisEngineBuilder { PyQisEngineBuilder { inner: pecos_qis::qis_engine(), + runtime_configured: false, } } /// Create a Selene-backed QIS Control Engine builder. #[pyfunction] -pub fn selene_engine() -> PyResult { - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { +#[pyo3(signature = (runtime_name = None))] +pub fn selene_engine(runtime_name: Option<&str>) -> PyResult { + let runtime = match runtime_name { + None | Some("selene_simple_runtime") => pecos_qis::selene_simple_runtime(), + Some(name) => pecos_qis::selene_runtime_auto(name), + } + .map_err(|e| { PyErr::new::(format!( "Failed to load Selene runtime: {e}" )) })?; Ok(PyQisEngineBuilder { inner: pecos_qis::qis_engine().runtime(runtime), + runtime_configured: true, }) } @@ -1176,6 +1237,32 @@ impl PyGeneralNoiseModelBuilder { }) } + /// Set the probability of global crosstalk during measurement operations + fn with_p_meas_crosstalk_global(&self, prob: f64) -> PyResult { + Ok(Self { + inner: self.inner.clone().with_p_meas_crosstalk_global(prob), + }) + } + + /// Set the probability of local crosstalk during measurement operations + fn with_p_meas_crosstalk_local(&self, prob: f64) -> PyResult { + Ok(Self { + inner: self.inner.clone().with_p_meas_crosstalk_local(prob), + }) + } + + /// Set the transition model for measurement crosstalk + fn with_p_meas_crosstalk_model( + &self, + model: std::collections::BTreeMap, + ) -> PyResult { + use std::collections::BTreeMap; + let btree_map: BTreeMap = model.into_iter().collect(); + Ok(Self { + inner: self.inner.clone().with_p_meas_crosstalk_model(&btree_map), + }) + } + /// Set the scaling factor for measurement errors fn with_meas_scale(&self, scale: f64) -> PyResult { Ok(Self { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c443778b2..bbe89aa7f 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -42,6 +42,9 @@ //! has_syndrome, causes_logical = influence_map.classify_fault(0, 1) # loc 0, X fault //! ``` +use crate::pecos_array::{Array, ArrayData}; +use pecos_core::gate_type::GateType; +use pecos_qec::fault_tolerance::PauliFrameLookup as RustPauliFrameLookup; use pecos_qec::fault_tolerance::dem_builder::{ ComparisonMethod as RustComparisonMethod, ContributionEffectSummary as RustContributionEffectSummary, @@ -51,7 +54,9 @@ use pecos_qec::fault_tolerance::dem_builder::{ DemSampler as RustNewDemSampler, DemSamplerBuilder as RustNewDemSamplerBuilder, DetectorErrorModel as RustDetectorErrorModel, DirectSourceFamily as RustDirectSourceFamily, EquivalenceResult as RustEquivalenceResult, FaultContribution as RustFaultContribution, - FaultSourceType as RustFaultSourceType, NoiseConfig, ParsedDem as RustParsedDem, + FaultSourceType as RustFaultSourceType, MeasurementCrosstalkDemMode, + MeasurementCrosstalkTransitionModel, NoiseConfig, PAULI_2Q_ORDER, ParsedDem as RustParsedDem, + PauliWeights, ReplacementBranchApproximation, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, compare_dems_exact as rust_compare_dems_exact, compare_dems_statistical as rust_compare_dems_statistical, @@ -66,9 +71,357 @@ use pecos_quantum::DagCircuit; use pecos_quantum::QubitId; use pyo3::Py; use pyo3::prelude::*; +use std::collections::BTreeMap; +use std::str::FromStr; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); +/// Per-shot detector rows paired with per-shot observable/DEM-output rows. +type PyDetectorObservableRows = (Vec>, Vec>); + +fn parse_p1_weights(weights: BTreeMap) -> PyResult { + use pecos_core::pauli::{X, Y, Z}; + + let mut entries = Vec::with_capacity(weights.len()); + let mut sum = 0.0; + for (label, weight) in weights { + let label = label.trim().to_ascii_uppercase(); + let pauli = match label.as_str() { + "X" => X(0), + "Y" => Y(0), + "Z" => Z(0), + _ => { + let msg = format!("p1_weights keys must be one of ['X', 'Y', 'Z'], got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + }; + if !weight.is_finite() || weight < 0.0 { + let msg = + format!("p1_weights[{label:?}] must be finite and non-negative, got {weight}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + sum += weight; + entries.push((pauli, weight)); + } + if (sum - 1.0).abs() >= 1.0e-6 { + let msg = format!("p1_weights relative probabilities must sum to 1.0, got {sum}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + Ok(PauliWeights::new(entries)) +} + +fn parse_p2_weights(weights: BTreeMap) -> PyResult { + use pecos_core::pauli::{X, Y, Z}; + + let mut entries = Vec::with_capacity(weights.len()); + let mut replacement_entries = Vec::new(); + let mut sum = 0.0; + for (label, weight) in weights { + let label = label.trim().to_ascii_uppercase(); + let (replacement, label) = match label.strip_prefix('*') { + Some(stripped) => (true, stripped.to_string()), + None => (false, label), + }; + let replacement_identity = replacement && label == "II"; + if !replacement_identity && !PAULI_2Q_ORDER.contains(&label.as_str()) { + let msg = format!( + "p2_weights keys must be one of {PAULI_2Q_ORDER:?} or prefixed with '*' for replacement branches, got {label:?}" + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + if !weight.is_finite() || weight < 0.0 { + let msg = + format!("p2_weights[{label:?}] must be finite and non-negative, got {weight}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let mut pauli = None; + for (qubit, ch) in label.chars().enumerate() { + let term = match ch { + 'I' => None, + 'X' => Some(X(qubit)), + 'Y' => Some(Y(qubit)), + 'Z' => Some(Z(qubit)), + _ => unreachable!("validated p2_weights label contains only I/X/Y/Z"), + }; + pauli = match (pauli, term) { + (None, None) => None, + (Some(existing), None) => Some(existing), + (None, Some(term)) => Some(term), + (Some(existing), Some(term)) => Some(existing & term), + }; + } + let pauli = if let Some(pauli) = pauli { + pauli + } else if replacement { + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + Vec::new(), + ) + } else { + return Err(pyo3::exceptions::PyValueError::new_err( + "plain p2_weights cannot contain identity pair 'II'; use '*II' for a replacement branch that only omits the gate", + )); + }; + sum += weight; + if replacement { + replacement_entries.push((pauli, weight)); + } else { + entries.push((pauli, weight)); + } + } + if (sum - 1.0).abs() >= 1.0e-6 { + let msg = format!("p2_weights relative probabilities must sum to 1.0, got {sum}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + Ok(PauliWeights::with_replacement(entries, replacement_entries)) +} + +fn parse_replacement_approximation( + value: Option, +) -> PyResult { + let Some(value) = value else { + return Ok(ReplacementBranchApproximation::default()); + }; + match value + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_") + .as_str() + { + "pauli_twirl_omitted_gate" | "pauli_twirl" | "twirl" => { + Ok(ReplacementBranchApproximation::PauliTwirlOmittedGate) + } + "branch_impact" | "replacement_branch_impact" | "impact" => { + Ok(ReplacementBranchApproximation::BranchImpact) + } + "exact_branch_replay" | "exact_replay" | "exact_branch" | "exact" => { + Ok(ReplacementBranchApproximation::ExactBranchReplay) + } + "ignore_gate_removal" | "ignore_removal" | "post_gate" | "postgate" => { + Ok(ReplacementBranchApproximation::IgnoreGateRemoval) + } + _ => Err(pyo3::exceptions::PyValueError::new_err( + "p2_replacement_approximation must be 'pauli_twirl_omitted_gate', 'branch_impact', 'exact_branch_replay', or 'ignore_gate_removal'", + )), + } +} + +fn parse_p2_gate_rates(rates: BTreeMap) -> PyResult> { + let mut parsed = BTreeMap::new(); + for (label, rate) in rates { + if !rate.is_finite() || rate < 0.0 { + let msg = + format!("p2_gate_rates[{label:?}] must be finite and non-negative, got {rate}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let gate_type = GateType::from_str(label.trim()).map_err(|err| { + let msg = format!("unsupported p2_gate_rates gate label {label:?}: {err}"); + pyo3::exceptions::PyValueError::new_err(msg) + })?; + if !gate_type.is_two_qubit() { + let msg = format!("p2_gate_rates keys must name two-qubit gates, got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + parsed.insert(gate_type, rate); + } + Ok(parsed) +} + +fn parse_p1_gate_rates(rates: BTreeMap) -> PyResult> { + let mut parsed = BTreeMap::new(); + for (label, rate) in rates { + if !rate.is_finite() || rate < 0.0 { + let msg = + format!("p1_gate_rates[{label:?}] must be finite and non-negative, got {rate}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let gate_type = GateType::from_str(label.trim()).map_err(|err| { + let msg = format!("unsupported p1_gate_rates gate label {label:?}: {err}"); + pyo3::exceptions::PyValueError::new_err(msg) + })?; + if !gate_type.is_single_qubit() { + let msg = format!("p1_gate_rates keys must name single-qubit gates, got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + parsed.insert(gate_type, rate); + } + Ok(parsed) +} + +fn parse_measurement_crosstalk_dem_mode( + value: Option, +) -> PyResult { + let Some(value) = value else { + return Ok(MeasurementCrosstalkDemMode::default()); + }; + match value + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_") + .as_str() + { + "omitted" | "omit" | "none" | "off" => Ok(MeasurementCrosstalkDemMode::Omitted), + "exact_deterministic" | "exact" | "deterministic" => { + Ok(MeasurementCrosstalkDemMode::ExactDeterministic) + } + "exact_deterministic_leakage_as_depolarizing" + | "exact_leakage_as_depolarizing" + | "deterministic_leakage_as_depolarizing" + | "leakage_as_depolarizing" => { + Ok(MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing) + } + "averaged_hidden_leakage_as_depolarizing" + | "average_hidden_leakage_as_depolarizing" + | "state_averaged_leakage_as_depolarizing" + | "averaged_leakage_as_depolarizing" => { + Ok(MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing) + } + _ => Err(pyo3::exceptions::PyValueError::new_err( + "measurement_crosstalk_dem_mode must be 'omitted', 'exact_deterministic', 'exact_deterministic_leakage_as_depolarizing', or 'averaged_hidden_leakage_as_depolarizing'", + )), + } +} + +fn parse_measurement_crosstalk_transition_model( + value: Option>, +) -> PyResult { + let Some(value) = value else { + return Ok(MeasurementCrosstalkTransitionModel::default()); + }; + let mut model = MeasurementCrosstalkTransitionModel::default(); + for (key, probability) in value { + if !probability.is_finite() || probability < 0.0 { + let msg = format!( + "measurement crosstalk transition probability for {key:?} must be finite and non-negative" + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + match key.trim().to_ascii_uppercase().replace(' ', "").as_str() { + "0->0" | "1->1" => {} + "0->1" => model.p_0_to_1 = probability, + "0->L" => model.p_0_to_leak = probability, + "1->0" => model.p_1_to_0 = probability, + "1->L" => model.p_1_to_leak = probability, + _ => { + let msg = format!( + "unsupported measurement crosstalk transition key {key:?}; expected 0->0, 0->1, 0->L, 1->0, 1->1, or 1->L" + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + } + } + if !model.is_valid() { + return Err(pyo3::exceptions::PyValueError::new_err( + "measurement crosstalk transition rows must sum to <= 1", + )); + } + Ok(model) +} + +fn apply_noise_options( + mut noise: NoiseConfig, + p_idle: Option, + t1: Option, + t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, + p1_weights: Option>, + p2_weights: Option>, + p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, + p1_gate_rates: Option>, +) -> PyResult { + noise.p_idle = p_idle.unwrap_or(0.0); + if let (Some(t1_val), Some(t2_val)) = (t1, t2) { + noise = noise.set_t1_t2(t1_val, t2_val); + } + if let Some(rz) = idle_rz { + noise = noise.set_idle_rz(rz); + } + if let Some(rate) = p_idle_linear_rate { + noise = noise.set_idle_linear_rate(rate); + } + if let Some(rate) = p_idle_quadratic_rate { + noise = noise.set_idle_quadratic_rate(rate); + } + if let Some(rate) = p_idle_x_linear_rate { + noise.p_idle_x_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_y_linear_rate { + noise.p_idle_y_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_linear_rate { + noise.p_idle_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_x_quadratic_rate { + noise.p_idle_x_quadratic_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_y_quadratic_rate { + noise.p_idle_y_quadratic_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_quadratic_rate { + noise.p_idle_quadratic_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_quadratic_sine_rate { + noise = noise.set_idle_quadratic_sine_rate(rate); + } + if let Some(rate) = p_idle_x_quadratic_sine_rate { + noise.p_idle_x_quadratic_sine_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_y_quadratic_sine_rate { + noise.p_idle_y_quadratic_sine_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_quadratic_sine_rate { + noise.p_idle_quadratic_sine_rate = rate.max(0.0); + } + if let Some(weights) = p1_weights { + noise = noise.set_p1_weights(parse_p1_weights(weights)?); + } + if let Some(weights) = p2_weights { + noise = noise.set_p2_weights(parse_p2_weights(weights)?); + } + if let Some(rates) = p2_gate_rates { + for (gate_type, rate) in parse_p2_gate_rates(rates)? { + noise = noise.set_p2_gate_rate(gate_type, rate); + } + } + if let Some(rates) = p1_gate_rates { + for (gate_type, rate) in parse_p1_gate_rates(rates)? { + noise = noise.set_p1_gate_rate(gate_type, rate); + } + } + noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( + p2_replacement_approximation, + )?); + if let Some(rate) = p_meas_crosstalk_local { + noise = noise.set_measurement_crosstalk_local_rate(rate); + } + if let Some(rate) = p_meas_crosstalk_global { + noise = noise.set_measurement_crosstalk_global_rate(rate); + } + noise = noise.set_measurement_crosstalk_transition_model( + parse_measurement_crosstalk_transition_model(p_meas_crosstalk_model)?, + ); + noise = noise.set_measurement_crosstalk_dem_mode(parse_measurement_crosstalk_dem_mode( + measurement_crosstalk_dem_mode, + )?); + Ok(noise) +} // Adapter for decoder factories that require `Send + Sync` trait objects. // Decoder implementations own their state; Python access remains GIL-mediated. @@ -76,11 +429,11 @@ struct SendWrapper(Box); unsafe impl Send for SendWrapper {} unsafe impl Sync for SendWrapper {} impl pecos_decoders::ObservableDecoder for SendWrapper { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { - self.0.decode_to_observables(syndrome) + ) -> Result { + self.0.decode_obs(syndrome) } } @@ -329,6 +682,16 @@ impl PyDagFaultInfluenceMap { self.inner.has_observable_flips(loc_idx, pauli) } + /// Replace this map's non-detector DEM outputs with another map's outputs. + /// + /// This is the Python equivalent of the canonical Rust DEM builder's + /// annotation merge: detector influence from `DagFaultAnalyzer` is kept, + /// while observable/tracked-Pauli outputs from `InfluenceBuilder` are used + /// for DEM output propagation. + fn merge_dem_outputs_from(&mut self, other: &PyDagFaultInfluenceMap) { + self.inner.merge_dem_outputs_from(&other.inner); + } + /// Check if a fault at the given location flips any tracked Pauli. /// /// Args: @@ -702,6 +1065,178 @@ impl PyInfluenceBuilder { } } +// ============================================================================= +// Pauli Frame Lookup +// ============================================================================= + +#[pyclass(name = "PauliFrameLookup", module = "pecos_rslib.qec")] +pub struct PyPauliFrameLookup { + inner: RustPauliFrameLookup, +} + +#[pymethods] +impl PyPauliFrameLookup { + /// Build a Pauli-frame lookup from positional tracked-Pauli annotations. + /// + /// Args: + /// dag: A `DagCircuit` carrying tracked-Pauli meta-gates. + /// detectors: Detector definitions as measurement-record offsets. + /// observables: Observable definitions as measurement-record offsets. + #[staticmethod] + #[pyo3(signature = (dag, detectors, observables))] + fn from_circuit( + dag: &crate::dag_circuit_bindings::PyDagCircuit, + detectors: Vec>, + observables: Vec>, + ) -> PyResult { + let inner = RustPauliFrameLookup::from_circuit(&dag.inner, &detectors, &observables) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) + } + + /// Number of Pauli-twirl mask sites. + #[getter] + fn num_pauli_sites(&self) -> usize { + self.inner.num_pauli_sites() + } + + /// Number of tracked-Pauli rows. + #[getter] + fn num_tracked_paulis(&self) -> usize { + self.inner.num_tracked_paulis() + } + + /// Number of detector columns. + #[getter] + fn num_detectors(&self) -> usize { + self.inner.num_detectors() + } + + /// Number of observable columns. + #[getter] + fn num_observables(&self) -> usize { + self.inner.num_observables() + } + + /// Return one tracked-Pauli row as `(detectors, observables)`. + fn row(&self, tracked_idx: usize) -> PyResult<(Vec, Vec)> { + let Some((detectors, observables)) = self.inner.row_effects(tracked_idx) else { + return Err(pyo3::exceptions::PyIndexError::new_err(format!( + "tracked_idx {tracked_idx} is out of range" + ))); + }; + Ok((detectors.to_vec(), observables.to_vec())) + } + + /// Decode a Pauli mask array into tracked-row firings. + fn mask_firings(&self, pauli_masks: &Bound<'_, pyo3::PyAny>) -> PyResult>> { + let (values, rows, cols) = extract_pauli_mask_values(pauli_masks)?; + self.inner + .mask_firings(&values, rows, cols) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + /// Compute per-shot detector/observable XOR patterns for the given masks. + fn compute_mask_xor( + &self, + pauli_masks: &Bound<'_, pyo3::PyAny>, + ) -> PyResult { + let (values, rows, cols) = extract_pauli_mask_values(pauli_masks)?; + self.inner + .compute_mask_xor(&values, rows, cols) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn __repr__(&self) -> String { + format!( + "PauliFrameLookup(num_pauli_sites={}, num_tracked_paulis={}, num_detectors={}, num_observables={})", + self.num_pauli_sites(), + self.num_tracked_paulis(), + self.num_detectors(), + self.num_observables(), + ) + } +} + +fn extract_pauli_mask_values( + pauli_masks: &Bound<'_, pyo3::PyAny>, +) -> PyResult<(Vec, usize, usize)> { + let array = Array::from_python_value(pauli_masks, None)?; + match &array.data { + ArrayData::I8(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I16(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I32(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I64(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::U8(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U16(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U32(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U64(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::Bool(_) + | ArrayData::F32(_) + | ArrayData::F64(_) + | ArrayData::Complex64(_) + | ArrayData::Complex128(_) + | ArrayData::Pauli(_) + | ArrayData::PauliString(_) => Err(pyo3::exceptions::PyTypeError::new_err( + "pauli_masks must be an integer Array with values 0=I, 1=X, 2=Y, 3=Z", + )), + } +} + +fn pauli_mask_shape(arr: &ndarray::ArrayD) -> PyResult<(usize, usize)> { + let shape = arr.shape(); + if shape.len() != 2 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks must be 2-D with shape (num_shots, num_pauli_sites), got shape {shape:?}" + ))); + } + Ok((shape[0], shape[1])) +} + +fn collect_signed_pauli_mask_values( + arr: &ndarray::ArrayD, +) -> PyResult<(Vec, usize, usize)> +where + T: Copy + Into, +{ + let (rows, cols) = pauli_mask_shape(arr)?; + let mut values = Vec::with_capacity(arr.len()); + for (idx, value) in arr.iter().copied().enumerate() { + let value = value.into(); + if !(0..=3).contains(&value) { + let row = idx / cols; + let col = idx % cols; + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks[{row}, {col}]={value} is outside 0..=3" + ))); + } + values.push(u8::try_from(value).expect("validated pauli mask value fits in u8")); + } + Ok((values, rows, cols)) +} + +fn collect_unsigned_pauli_mask_values( + arr: &ndarray::ArrayD, +) -> PyResult<(Vec, usize, usize)> +where + T: Copy + Into, +{ + let (rows, cols) = pauli_mask_shape(arr)?; + let mut values = Vec::with_capacity(arr.len()); + for (idx, value) in arr.iter().copied().enumerate() { + let value = value.into(); + if value > 3 { + let row = idx / cols; + let col = idx % cols; + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks[{row}, {col}]={value} is outside 0..=3" + ))); + } + values.push(u8::try_from(value).expect("validated pauli mask value fits in u8")); + } + Ok((values, rows, cols)) +} + // ============================================================================= // Detector Error Model // ============================================================================= @@ -899,10 +1434,34 @@ fn contribution_record_to_pydict( RustDirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", RustDirectSourceFamily::TwoLocationComponent => "TwoLocationComponent", RustDirectSourceFamily::TwoLocationOneSidedComponent => "TwoLocationOneSidedComponent", + RustDirectSourceFamily::TwoLocationReplacementBranchImpact => { + "TwoLocationReplacementBranchImpact" + } + RustDirectSourceFamily::TwoLocationExactReplacementBranch => { + "TwoLocationExactReplacementBranch" + } + RustDirectSourceFamily::MeasurementCrosstalk => "MeasurementCrosstalk", RustDirectSourceFamily::Other => "Other", }; dict.set_item("direct_source_family", family_label)?; } + dict.set_item("replacement_branch", contribution.replacement_branch)?; + if let Some(parts) = &contribution.source_component_effects { + dict.set_item( + "source_component_detectors", + parts + .iter() + .map(|part| part.detectors.to_vec()) + .collect::>(), + )?; + dict.set_item( + "source_component_dem_outputs", + parts + .iter() + .map(|part| part.dem_outputs.to_vec()) + .collect::>(), + )?; + } match contribution.source_type { RustFaultSourceType::Direct => { @@ -952,26 +1511,80 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] + #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, p1: f64, p2: f64, p_meas: f64, p_prep: f64, + p_idle: Option, + t1: Option, + t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, + p1_weights: Option>, + p2_weights: Option>, + p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, + p1_gate_rates: Option>, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; + let noise = apply_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + p1_weights, + p2_weights, + p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, + p2_gate_rates, + p1_gate_rates, + )?; if let Ok(dag) = circuit.extract::>() { - let inner = DemBuilder::try_from_circuit(&dag.inner, p1, p2, p_meas, p_prep) + let inner = DemBuilder::try_from_circuit_with_noise_config(&dag.inner, noise) .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; Ok(Self { inner }) } else if let Ok(tc) = circuit.extract::>() { - let inner = DemBuilder::try_from_tick_circuit(&tc.inner, p1, p2, p_meas, p_prep) + let inner = DemBuilder::try_from_tick_circuit_with_noise_config(&tc.inner, noise) .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; Ok(Self { inner }) } else { @@ -1032,11 +1645,11 @@ impl PyDetectorErrorModel { self.inner.to_string() } - /// Convert the DEM to a string with decomposed representations. + /// Convert the DEM to a string with source-decomposed representations. /// - /// For 2-detector mechanisms, outputs multiple equivalent representations - /// including L0 cancellation forms where available. Hyperedge errors - /// (affecting 3+ detectors) are decomposed into graphlike components. + /// Faults are decomposed only using component structure attached to the + /// original source contribution. Residual hyperedges remain hyperedges + /// instead of being rewritten by an ambient graphlike search. /// /// Returns: /// A string in DEM format with decomposed representations. @@ -1044,6 +1657,43 @@ impl PyDetectorErrorModel { self.inner.to_string_decomposed() } + /// Convert the DEM to source-decomposed text. + /// + /// Only decomposition components attached to the original fault source are + /// used. Residual hyperedges remain hyperedges instead of being rewritten + /// by an ambient graphlike search. + fn to_string_source_decomposed(&self) -> String { + self.inner.to_string_source_decomposed() + } + + /// Convert the DEM to a source-informed graphlike decomposition. + /// + /// Source-carried components are recursively decomposed only using + /// graphlike pieces that are themselves source-carried components in this + /// DEM. Residual hyperedges remain hyperedges. + fn to_string_source_graphlike_decomposed(&self) -> String { + self.inner.to_string_source_graphlike_decomposed() + } + + /// Convert the DEM to a terminal-only graphlike projection. + /// + /// Raw mechanisms are first grouped exactly as in `to_string()`. Each raw + /// effect is then projected to graphlike terminal components using detector + /// coordinates. This is a decoder-facing representation for graph matchers, + /// not source-proof decomposition. + fn to_string_terminal_graphlike_decomposed(&self) -> String { + self.inner.to_string_terminal_graphlike_decomposed() + } + + /// Convert the DEM using the explicit historical graphlike-search renderer. + /// + /// This may decompose residual hyperedges by searching for graphlike + /// mechanisms elsewhere in the DEM, so it should be treated as a + /// compatibility/diagnostic representation rather than source proof. + fn to_string_graphlike_search_decomposed(&self) -> String { + self.inner.to_string_graphlike_search_decomposed() + } + /// Convert the DEM to a string with an explicit direct-2det render policy. fn to_string_decomposed_with_two_detector_direct_policy( &self, @@ -1147,6 +1797,19 @@ impl PyDetectorErrorModel { .collect() } + /// Returns per-contribution render records for the source-informed + /// graphlike renderer. + fn contribution_source_graphlike_render_records( + &self, + py: Python<'_>, + ) -> PyResult>> { + self.inner + .contribution_source_graphlike_render_records() + .into_iter() + .map(|record| contribution_render_record_to_pydict(py, record, &self.inner)) + .collect() + } + /// Returns per-contribution render records under an explicit direct-2det /// render policy. fn contribution_render_records_with_two_detector_direct_policy( @@ -1237,6 +1900,7 @@ pub struct PyDemBuilder { observables_json: Option, num_measurements: Option, measurement_order: Option>, + exact_branch_circuit: Option, } #[pymethods] @@ -1254,6 +1918,7 @@ impl PyDemBuilder { observables_json: None, num_measurements: None, measurement_order: None, + exact_branch_circuit: None, } } @@ -1270,7 +1935,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1282,17 +1947,57 @@ impl PyDemBuilder { t1: Option, t2: Option, idle_rz: Option, - ) -> PyRefMut<'_, Self> { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let (Some(t1_val), Some(t2_val)) = (t1, t2) { - noise = noise.set_t1_t2(t1_val, t2_val); - } - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } - slf.noise = noise; - slf + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, + p1_weights: Option>, + p2_weights: Option>, + p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, + p1_gate_rates: Option>, + ) -> PyResult> { + slf.noise = apply_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + p1_weights, + p2_weights, + p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, + p2_gate_rates, + p1_gate_rates, + )?; + Ok(slf) } /// Set the detector definitions from JSON. @@ -1351,6 +2056,20 @@ impl PyDemBuilder { slf } + /// Attach the original circuit for exact replacement-branch replay. + /// + /// This is only needed when using `p2_replacement_approximation="exact_branch_replay"` + /// with starred p2 replacement branches. The influence map still determines + /// ordinary Pauli propagation; the circuit context lets PECOS replay the + /// omitted-gate branch and fail loudly if it is not DEM-representable. + fn with_exact_branch_replay_circuit<'py>( + mut slf: PyRefMut<'py, Self>, + circuit: &crate::dag_circuit_bindings::PyDagCircuit, + ) -> PyRefMut<'py, Self> { + slf.exact_branch_circuit = Some(circuit.inner.clone()); + slf + } + /// Build the Detector Error Model. /// /// Returns: @@ -1364,6 +2083,10 @@ impl PyDemBuilder { let mut builder = RustDemBuilder::new(&self.influence_map).with_noise_config(self.noise.clone()); + if let Some(ref circuit) = self.exact_branch_circuit { + builder = builder.with_exact_branch_replay_context(circuit); + } + if let Some(num) = self.num_measurements { builder = builder.with_num_measurements(num); } @@ -1419,10 +2142,10 @@ struct WeightedUfObservableDecoder { } impl pecos_decoders::ObservableDecoder for WeightedUfObservableDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { let arr = ndarray::Array1::from_vec(syndrome.to_vec()); // bits_per_step=1: grow one bit at a time, sorted by LLR weight. // bits_per_step=0 with non-empty LLRs causes the C++ UF decoder to @@ -1433,7 +2156,7 @@ impl pecos_decoders::ObservableDecoder for WeightedUfObservableDecoder { .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; Ok(self .dcm - .observables_mask_from_correction(result.decoding.as_slice().unwrap_or(&[]))) + .observables_obsmask_from_correction(result.decoding.as_slice().unwrap_or(&[]))) } } @@ -1447,10 +2170,10 @@ struct RelabeledObservableDecoder { } impl pecos_decoders::ObservableDecoder for RelabeledObservableDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { // Relabel syndrome into the expanded vertex space (detectors + virtual + gap) let expected = self.decoder.num_nodes(); let mut relabeled = vec![0u8; expected]; @@ -1467,10 +2190,10 @@ impl pecos_decoders::ObservableDecoder for RelabeledObservableDecoder { .decoder .decode(&arr.view()) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; - let mut mask = 0u64; + let mut mask = pecos_decoder_core::obs_mask::ObsMask::new(); for (i, &v) in result.observable.iter().enumerate() { if v != 0 { - mask |= 1 << i; + mask.set(i); } } Ok(mask) @@ -1511,7 +2234,7 @@ fn create_observable_decoder( }; match decoder_type { - "pymatching" => { + "pymatching" | "pymatching_correlated" => { // Default: correlated matching enabled (exploits X-Z correlations // from depolarizing noise for ~20% fewer errors at d>=5). let d = PyMatchingDecoder::from_dem_with_correlations(dem, true) @@ -1582,6 +2305,11 @@ fn create_observable_decoder( use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; // Use absolute weight scaling. Fusion Blossom uses integer weights; // we multiply by 1000 for precision (matching the internal 1000x @@ -1625,6 +2353,11 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), @@ -1729,6 +2462,11 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; // Group detectors by time coordinate for round-contiguous relabeling. let mut round_groups: std::collections::BTreeMap> = @@ -2024,10 +2762,16 @@ fn create_observable_decoder( |e| PyErr::new::(e.to_string()), )?; + // Reject negative-weight edges (error priors p > 0.5) as a Python error + // rather than panicking on the negative-weight assert; the + // >64-observable guard now lives in `from_matching_graph`. + pecos_decoders::UfDecoder::check_non_negative_weights(&graph) + .map_err(|e| PyErr::new::(e.to_string()))?; let uf = pecos_decoders::UfDecoder::from_matching_graph( &graph, pecos_decoders::UfDecoderConfig::balanced(), - ); + ) + .map_err(|e| PyErr::new::(e.to_string()))?; let two_pass = TwoPassDecoder::new(uf, base_weights, corr_table); Ok(Box::new(two_pass)) } @@ -2057,6 +2801,11 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -2120,6 +2869,11 @@ fn create_observable_decoder( // Build Fusion Blossom as the matching backend. let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -2164,6 +2918,11 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; // Build Fusion Blossom. let config = FusionBlossomConfig { @@ -2230,6 +2989,11 @@ fn create_observable_decoder( // Build Fusion Blossom from decomposed DEM. let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -2556,19 +3320,19 @@ fn create_observable_decoder( } Ok(Box::new(EnsembleDecoder::new(members))) } - // Per-observable subgraph decoder: requires stab_coords from Python. + // Per-logical-operator subgraph decoder: requires stab_coords from Python. // This is NOT callable from the string-based create_observable_decoder API. - // Use the Python ObservableSubgraphDecoder class directly instead. - s if s == "observable_subgraph" || s.starts_with("observable_subgraph:") => { + // Use the Python LogicalSubgraphDecoder class directly instead. + s if s == "logical_subgraph" || s.starts_with("logical_subgraph:") => { Err(PyErr::new::( - "observable_subgraph decoder requires stab_coords. \ - Use pecos_rslib.qec.ObservableSubgraphDecoder class directly.", + "logical_subgraph decoder requires stab_coords. \ + Use pecos_rslib.qec.LogicalSubgraphDecoder class directly.", )) } _ => Err(PyErr::new::(format!( "Unsupported decoder_type: {decoder_type}. \ Supported: pymatching, tesseract, mwpf, pecos_uf (or pecos_uf:fast/balanced/accurate), \ - observable_subgraph, ensemble:d1,d2,..., bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp." + logical_subgraph, ensemble:d1,d2,..., bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp." ))), } } @@ -2610,8 +3374,33 @@ impl PySampleBatch { } } - /// Extract observable mask for one shot. + /// Reject a batch that cannot be represented by the legacy `u64` observable + /// APIs (more than 64 observable columns). Callers with >64 observables must + /// use the wide `LogicalSubgraphDecoder` decode/decode_count paths, which + /// return arbitrary-precision Python ints. Call this up front in every + /// `u64`-returning public method before [`Self::extract_obs_mask`]. + fn ensure_narrow_observables(&self) -> PyResult<()> { + if self.obs_columns.len() > 64 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "SampleBatch has {} observable columns, exceeding the 64-observable limit of \ + this u64-based API; use the wide LogicalSubgraphDecoder decode/decode_count \ + paths (arbitrary-precision int) for more than 64 observables", + self.obs_columns.len() + ))); + } + Ok(()) + } + + /// Extract observable mask for one shot (`u64`; observables 0..=63 only). + /// + /// The caller must have rejected wide batches via + /// [`Self::ensure_narrow_observables`] first; with >64 observable columns the + /// `1u64 << obs_idx` below would overflow. fn extract_obs_mask(&self, shot: usize) -> u64 { + debug_assert!( + self.obs_columns.len() <= 64, + "extract_obs_mask requires <=64 observable columns; call ensure_narrow_observables first" + ); let word_idx = shot / 64; let bit_mask = 1u64 << (shot % 64); let mut mask = 0u64; @@ -2623,6 +3412,20 @@ impl PySampleBatch { mask } + /// Extract the observable mask for one shot as a wide [`ObsMask`], with no + /// 64-observable cap (the columnar storage already supports >64 columns). + fn extract_obs_mask_wide(&self, shot: usize) -> pecos_decoder_core::obs_mask::ObsMask { + let word_idx = shot / 64; + let bit_mask = 1u64 << (shot % 64); + let mut mask = pecos_decoder_core::obs_mask::ObsMask::new(); + for (obs_idx, col) in self.obs_columns.iter().enumerate() { + if col[word_idx] & bit_mask != 0 { + mask.set(obs_idx); + } + } + mask + } + /// Build from columnar data (from generate_samples). fn from_columnar( det_columns: Vec>, @@ -2638,8 +3441,12 @@ impl PySampleBatch { } } - /// Build from row-major data (from Python constructor). - fn from_row_major(detection_events: Vec>, observable_masks: Vec) -> Self { + /// Build from row-major data (from Python constructor). Observable masks are + /// wide [`ObsMask`]es, so more than 64 observables are stored without loss. + fn from_row_major( + detection_events: Vec>, + observable_masks: &[pecos_decoder_core::obs_mask::ObsMask], + ) -> Self { let num_shots = detection_events.len(); let num_detectors = detection_events.first().map_or(0, Vec::len); let num_words = num_shots.div_ceil(64); @@ -2656,20 +3463,19 @@ impl PySampleBatch { } } - // Find max observable index + // One observable column per observable index; sized to the highest set + // bit across all shots (supports >64 observables). let max_obs = observable_masks .iter() - .map(|m| 64 - m.leading_zeros() as usize) + .filter_map(|m| m.iter_set_bits().max()) .max() - .unwrap_or(0); + .map_or(0, |b| b + 1); let mut obs_columns = vec![vec![0u64; num_words]; max_obs]; - for (shot, &mask) in observable_masks.iter().enumerate() { + for (shot, mask) in observable_masks.iter().enumerate() { let word_idx = shot / 64; let bit_mask = 1u64 << (shot % 64); - for (obs_idx, obs_column) in obs_columns.iter_mut().enumerate().take(max_obs) { - if mask & (1u64 << obs_idx) != 0 { - obs_column[word_idx] |= bit_mask; - } + for obs_idx in mask.iter_set_bits() { + obs_columns[obs_idx][word_idx] |= bit_mask; } } @@ -2688,10 +3494,15 @@ impl PySampleBatch { /// /// Args: /// detection_events: List of syndromes, each a list of u8 (0/1). - /// observable_masks: List of u64 true observable flip masks. + /// observable_masks: List of true observable flip masks as Python ints + /// (arbitrary precision; bit ``i`` = observable ``i``, so more than 64 + /// observables are supported). #[new] #[pyo3(signature = (detection_events, observable_masks))] - fn new(detection_events: Vec>, observable_masks: Vec) -> PyResult { + fn new( + detection_events: Vec>, + observable_masks: Vec>, + ) -> PyResult { if detection_events.len() != observable_masks.len() { return Err(pyo3::exceptions::PyValueError::new_err(format!( "detection_events ({}) and observable_masks ({}) must have same length", @@ -2709,7 +3520,11 @@ impl PySampleBatch { ))); } } - Ok(Self::from_row_major(detection_events, observable_masks)) + let masks: Vec = observable_masks + .iter() + .map(py_to_obsmask) + .collect::>()?; + Ok(Self::from_row_major(detection_events, &masks)) } /// Number of shots in this batch. @@ -2731,8 +3546,9 @@ impl PySampleBatch { Ok(buf) } - /// Get the expected observable mask for shot `i`. + /// Get the expected observable mask for shot `i` (`u64`; <=64 observables). fn get_observable_mask(&self, i: usize) -> PyResult { + self.ensure_narrow_observables()?; if i >= self.num_shots { return Err(PyErr::new::(format!( "Shot index {i} out of range (num_shots={})", @@ -2742,14 +3558,27 @@ impl PySampleBatch { Ok(self.extract_obs_mask(i)) } + /// Observable mask for shot `i` as a Python ``int`` (arbitrary precision, so + /// more than 64 observables are not truncated). + fn get_observable_mask_wide(&self, py: Python<'_>, i: usize) -> PyResult> { + if i >= self.num_shots { + return Err(PyErr::new::(format!( + "Shot index {i} out of range (num_shots={})", + self.num_shots + ))); + } + obsmask_to_py(py, &self.extract_obs_mask_wide(i)) + } + /// Decode all samples with the given decoder type and return the error count. /// /// This runs entirely in Rust -- no per-shot Python crossing. /// /// Args: /// dem: DEM string in standard DEM text format for the decoder. - /// `decoder_type`: "pymatching", "tesseract", "`bp_osd`", "`bp_lsd`", "`union_find`", - /// "`relay_bp`", or "`min_sum_bp`". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", "`bp_osd`", + /// "`bp_lsd`", "`union_find`", "`relay_bp`", or "`min_sum_bp`". /// /// Returns: /// Number of logical errors. @@ -2760,14 +3589,54 @@ impl PySampleBatch { let mut syndrome = vec![0u8; self.num_detectors]; for i in 0..self.num_shots { self.extract_syndrome(i, &mut syndrome); - let predicted = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); - if predicted != self.extract_obs_mask(i) { + // Wide ObsMask comparison: inline (one stack word) for the typical + // <=64 observables, correct without truncation beyond. A decode + // failure counts as a logical error (matching the prior sentinel). + let is_error = decoder + .decode_obs(&syndrome) + .map_or(true, |p| p != self.extract_obs_mask_wide(i)); + if is_error { errors += 1; } } Ok(errors) } + /// Decode every shot and return the predicted observable mask per shot. + /// + /// Mirrors `decode_count` but returns the raw per-shot predictions instead + /// of an aggregate error count, so callers can localize disagreements + /// against a reference decoder. + /// + /// Args: + /// dem: DEM string for the decoder. + /// `decoder_type`: Decoder type string. + /// + /// Returns: + /// List of predicted observable masks (Python ints; arbitrary precision, + /// so more than 64 observables are not truncated), one per shot. + #[pyo3(signature = (dem, decoder_type="pymatching"))] + fn decode_each( + &self, + py: Python<'_>, + dem: &str, + decoder_type: &str, + ) -> PyResult>> { + let mut decoder = create_observable_decoder(dem, decoder_type)?; + let mut predictions = Vec::with_capacity(self.num_shots); + let mut syndrome = vec![0u8; self.num_detectors]; + for i in 0..self.num_shots { + self.extract_syndrome(i, &mut syndrome); + // Propagate a decode failure rather than masking it as a sentinel + // observable value (which would read as a spurious disagreement). + let predicted = decoder + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + predictions.push(obsmask_to_py(py, &predicted)?); + } + Ok(predictions) + } + /// Parallel decode: distributes samples across rayon workers. /// /// Each worker creates its own decoder instance. Faster for slow decoders. @@ -2807,7 +3676,8 @@ impl PySampleBatch { s }) .collect(); - let observable_masks: Vec = (0..n).map(|i| self.extract_obs_mask(i)).collect(); + let observable_masks: Vec = + (0..n).map(|i| self.extract_obs_mask_wide(i)).collect(); let total_errors: usize = pool.install(|| { (0..n) @@ -2815,10 +3685,11 @@ impl PySampleBatch { .map_init( || create_observable_decoder(&dem_str, &dt).unwrap(), |decoder, i| { - let predicted = decoder - .decode_to_observables(&detection_events[i]) - .unwrap_or(u64::MAX); - usize::from(predicted != observable_masks[i]) + usize::from( + decoder + .decode_obs(&detection_events[i]) + .map_or(true, |p| p != observable_masks[i]), + ) }, ) .sum() @@ -2865,17 +3736,19 @@ impl PySampleBatch { .decode_batch_with_config(&flat, self.num_shots, num_detectors, config) .map_err(|e| PyErr::new::(e.to_string()))?; - // Count errors by comparing predictions to true observable masks + // Count errors by comparing predictions to true observable masks. The + // predicted mask is a wide ObsMask (inline for <=64 observables, correct + // beyond), so a DEM with more than 64 observables is not truncated. let num_observables = decoder.num_observables(); let mut num_errors = 0usize; for (i, prediction) in result.predictions.iter().enumerate() { - let mut predicted_mask = 0u64; + let mut predicted = pecos_decoder_core::obs_mask::ObsMask::new(); for (j, &v) in prediction.iter().enumerate() { if v != 0 && j < num_observables { - predicted_mask |= 1 << j; + predicted.set(j); } } - if predicted_mask != self.extract_obs_mask(i) { + if predicted != self.extract_obs_mask_wide(i) { num_errors += 1; } } @@ -2907,10 +3780,10 @@ impl PySampleBatch { for i in 0..self.num_shots { self.extract_syndrome(i, &mut syndrome); let t0 = Instant::now(); - let predicted = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); + let predicted = decoder.decode_obs(&syndrome); let elapsed = t0.elapsed().as_secs_f64(); per_shot_seconds.push(elapsed); - if predicted != self.extract_obs_mask(i) { + if predicted.map_or(true, |p| p != self.extract_obs_mask_wide(i)) { num_errors += 1; } } @@ -2966,8 +3839,8 @@ impl PySampleBatch { s }) .collect(); - let observable_masks: Vec = (0..self.num_shots) - .map(|i| self.extract_obs_mask(i)) + let observable_masks: Vec = (0..self.num_shots) + .map(|i| self.extract_obs_mask_wide(i)) .collect(); // Each worker decodes a slice of shots and returns (errors, per_shot_times). @@ -2988,11 +3861,9 @@ impl PySampleBatch { for i in start..end { let t0 = std::time::Instant::now(); - let predicted = decoder - .decode_to_observables(&detection_events[i]) - .unwrap_or(u64::MAX); + let predicted = decoder.decode_obs(&detection_events[i]); times.push(t0.elapsed().as_secs_f64()); - if predicted != observable_masks[i] { + if predicted.map_or(true, |p| p != observable_masks[i]) { errors += 1; } } @@ -3135,7 +4006,8 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, idle_rz=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] + #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, p1: f64, @@ -3143,13 +4015,59 @@ impl PyDemSampler { p_meas: f64, p_prep: f64, p_idle: Option, + t1: Option, + t2: Option, idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, + p1_weights: Option>, + p2_weights: Option>, + p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, + p1_gate_rates: Option>, ) -> PyResult { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } + let noise = apply_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + p1_weights, + p2_weights, + p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, + p2_gate_rates, + p1_gate_rates, + )?; // Accept both DagCircuit and TickCircuit if let Ok(dag) = @@ -3259,7 +4177,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3272,12 +4190,57 @@ impl PyDemSampler { p_idle: Option, t1: Option, t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, + p1_weights: Option>, + p2_weights: Option>, + p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, + p1_gate_rates: Option>, ) -> PyResult { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let (Some(t1_val), Some(t2_val)) = (t1, t2) { - noise = noise.set_t1_t2(t1_val, t2_val); - } + let noise = apply_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + p1_weights, + p2_weights, + p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, + p2_gate_rates, + p1_gate_rates, + )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) .with_detectors(detectors, observables) @@ -3399,6 +4362,63 @@ impl PyDemSampler { self.inner.sample_batch(num_shots, &mut rng) } + /// Sample multiple shots and XOR a known Pauli-frame mask into the outputs. + /// + /// Args: + /// `num_shots`: Number of shots to sample. + /// lookup: Pauli-frame lookup built from the same circuit metadata. + /// `pauli_masks`: Integer array with shape `(num_shots, num_pauli_sites)`. + /// Values are 0=I, 1=X, 2=Y, 3=Z. + /// seed: Optional random seed for reproducibility. + /// + /// Returns: + /// Tuple of (`all_detection_events`, `all_dem_output_flips`). + #[pyo3(signature = (num_shots, lookup, pauli_masks, seed=None))] + fn sample_batch_with_pauli_masks( + &self, + num_shots: usize, + lookup: &PyPauliFrameLookup, + pauli_masks: &Bound<'_, pyo3::PyAny>, + seed: Option, + ) -> PyResult { + use pecos_random::PecosRng; + use rand::RngExt; + + if lookup.inner.num_detectors() != self.inner.num_outputs() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli frame lookup has {} detector(s), sampler has {}", + lookup.inner.num_detectors(), + self.inner.num_outputs() + ))); + } + if lookup.inner.num_observables() != self.inner.num_dem_outputs() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli frame lookup has {} observable(s), sampler has {}", + lookup.inner.num_observables(), + self.inner.num_dem_outputs() + ))); + } + + let (mask_values, mask_rows, mask_cols) = extract_pauli_mask_values(pauli_masks)?; + let mut rng = match seed { + Some(s) => PecosRng::seed_from_u64(s), + None => PecosRng::seed_from_u64(rand::rng().random()), + }; + + let (mut det_events, mut obs_flips) = self.inner.sample_batch(num_shots, &mut rng); + lookup + .inner + .apply_mask_values( + &mask_values, + mask_rows, + mask_cols, + &mut det_events, + &mut obs_flips, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((det_events, obs_flips)) + } + /// Sample direct tracked-Pauli flips. /// /// Raises: @@ -3573,7 +4593,9 @@ impl PyDemSampler { /// Args: /// dem: DEM string in standard DEM text format for the decoder. /// `num_shots`: Number of shots to sample and decode. - /// `decoder_type`: "pymatching" or "tesseract". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", or another + /// decoder accepted by `create_observable_decoder`. /// seed: Optional random seed for reproducibility. /// /// Returns: @@ -3601,9 +4623,14 @@ impl PyDemSampler { for _ in 0..num_shots { let (det_events, obs_flips) = self.inner.sample(&mut rng); let syndrome: Vec = det_events.iter().map(|&b| u8::from(b)).collect(); - let predicted_mask = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); - let true_mask = self.inner.observable_mask_from_dem_output_flips(&obs_flips); - if (predicted_mask & observable_mask) != true_mask { + let mut predicted = decoder + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + predicted &= &observable_mask; + let true_mask = self + .inner + .observable_mask_from_dem_output_flips(&obs_flips, &observable_mask); + if predicted != true_mask { errors += 1; } } @@ -3618,7 +4645,9 @@ impl PyDemSampler { /// Args: /// dem: DEM string in standard DEM text format for the decoder. /// `num_shots`: Number of shots to sample and decode. - /// `decoder_type`: "pymatching", "tesseract", "`bp_osd`", "`bp_lsd`", or "`union_find`". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", "`bp_osd`", + /// "`bp_lsd`", or "`union_find`". /// seed: Optional base random seed. Each thread gets seed + `thread_id`. /// `num_workers`: Number of parallel workers (default: number of CPUs). /// @@ -3675,10 +4704,13 @@ impl PyDemSampler { for _ in 0..my_shots { let (det_events, obs_flips) = my_sampler.sample(&mut my_rng); let syndrome: Vec = det_events.iter().map(|&b| u8::from(b)).collect(); - let predicted = - decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); - let truth = my_sampler.observable_mask_from_dem_output_flips(&obs_flips); - if (predicted & observable_mask) != truth { + let mut predicted = decoder + .decode_obs(&syndrome) + .unwrap_or_else(|_| observable_mask.clone()); + predicted &= &observable_mask; + let truth = my_sampler + .observable_mask_from_dem_output_flips(&obs_flips, &observable_mask); + if predicted != truth { errors += 1; } } @@ -3730,7 +4762,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -3742,17 +4774,57 @@ impl PyDemSamplerBuilder { t1: Option, t2: Option, idle_rz: Option, - ) -> PyRefMut<'_, Self> { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let (Some(t1_val), Some(t2_val)) = (t1, t2) { - noise = noise.set_t1_t2(t1_val, t2_val); - } - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } - slf.noise = noise; - slf + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, + p1_weights: Option>, + p2_weights: Option>, + p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, + p1_gate_rates: Option>, + ) -> PyResult> { + slf.noise = apply_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + p1_weights, + p2_weights, + p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, + p2_gate_rates, + p1_gate_rates, + )?; + Ok(slf) } /// Set detector definitions from JSON. @@ -4410,40 +5482,122 @@ impl PyCssUfDecoder { // Observable Subgraph Decoder (Python class) // ============================================================================= -/// Per-observable subgraph decoder for transversal gates. +/// Per-logical-operator subgraph decoder for transversal gates. /// -/// Partitions a DEM into per-observable graphlike subgraphs using +/// Partitions a DEM into per-logical-operator graphlike subgraphs using /// stabilizer coordinate information, then decodes each independently. /// /// Args: /// dem: DEM string with detector coordinate declarations. /// `stab_coords`: List of dicts, one per logical qubit. Each dict has /// keys "X" and "Z" mapping to lists of (x, y) ancilla coordinates. -/// `inner_decoder`: Inner decoder type string (default "`pecos_uf:fast`"). +/// `inner_decoder`: Inner decoder type string (default +/// "`fusion_blossom_serial`", exact MWPM -- accurate and fast across +/// distances, bundled). The best choice is circuit-dependent: +/// `pecos_uf:bp` (PECOS-native belief-propagation + union-find, +/// dependency-free) is competitive on memory and at small distance and is +/// the right pick when you want the pure-native path, but its grow+peel +/// matching is both LESS accurate and SLOWER at higher distance / +/// multi-observable circuits. `belief_matching` matches fusion's accuracy +/// but is slower. /// /// Example: -/// >>> decoder = `ObservableSubgraphDecoder`( +/// >>> decoder = `LogicalSubgraphDecoder`( /// ... `dem_str`, /// ... [{"X": [(1,0), (3,1)], "Z": [(0,3), (1,1)]}], -/// ... "`pecos_uf:fast`", +/// ... "`fusion_blossom_serial`", /// ... ) /// >>> obs = decoder.decode(syndrome) -#[pyclass(name = "ObservableSubgraphDecoder", module = "pecos_rslib.qec")] -pub struct PyObservableSubgraphDecoder { - inner: pecos_decoder_core::observable_subgraph::ObservableSubgraphDecoder, +/// Convert a wide observable mask to a Python integer (arbitrary precision). +/// +/// `<= 64` observables become a plain `int` from the single `u64` (identical to +/// the historical return); `> 64` observables become a big `int` built from the +/// mask's little-endian words, with no truncation. +fn obsmask_to_py( + py: Python<'_>, + mask: &pecos_decoder_core::obs_mask::ObsMask, +) -> PyResult> { + if let Some(v) = mask.to_u64() { + return Ok(v.into_pyobject(py)?.into_any().unbind()); + } + let mut bytes = Vec::with_capacity(mask.words().len() * 8); + for &word in mask.words() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let py_bytes = pyo3::types::PyBytes::new(py, &bytes); + let int_type = py.get_type::(); + Ok(int_type + .call_method1("from_bytes", (py_bytes, "little"))? + .unbind()) +} + +/// Convert a Python integer (arbitrary precision) to a wide observable mask. +/// +/// Inverse of [`obsmask_to_py`]: reads the int's little-endian bytes and packs +/// them into `u64` words, so observable indices >= 64 are preserved. +fn py_to_obsmask( + value: &pyo3::Bound<'_, pyo3::PyAny>, +) -> PyResult { + let bit_length: usize = value.call_method0("bit_length")?.extract()?; + let nbytes = bit_length.div_ceil(8).max(1); + let bytes: Vec = value + .call_method1("to_bytes", (nbytes, "little"))? + .extract()?; + let words: Vec = bytes + .chunks(8) + .map(|chunk| { + let mut buf = [0u8; 8]; + buf[..chunk.len()].copy_from_slice(chunk); + u64::from_le_bytes(buf) + }) + .collect(); + Ok(pecos_decoder_core::obs_mask::ObsMask::from_words(&words)) +} + +#[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] +pub struct PyLogicalSubgraphDecoder { + inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, + /// The inner per-observable decoder backend selected at construction + /// (e.g. `"fusion_blossom_serial"`). `decode_count_parallel` reuses this so + /// the parallel workers match the serial path unless the caller overrides. + inner_decoder: String, } #[pymethods] -impl PyObservableSubgraphDecoder { +impl PyLogicalSubgraphDecoder { + // Default inner is `fusion_blossom_serial`: exact MWPM on each per-observable + // subgraph, bundled (no optional dependency). This is now backed by a powered + // threshold/CI study (`examples/surface/inner_decoder_study.py`; memory + + // transversal-CX, d=3/5/7, 3 seeds pooled = 150-300k shots/cell, Jeffreys + // intervals), NOT just policy: + // * Accuracy: fusion is statistically tied with pymatching/belief_matching/ + // tesseract (these per-observable DEMs are graphlike, so exact MWPM is + // optimal) and STRICTLY beats `pecos_uf:bp` at every d>=5 cell -- 1.4-2.7x + // lower LER with DISJOINT Jeffreys intervals, both families. Tied at d=3. + // * Threshold: fusion ~0.9% vs `pecos_uf:bp` ~0.7% (bp also breaks down sooner). + // * Speed: at d=7 bp's grow+peel blows up (CX 7.1ms/shot vs fusion 1.2ms); + // "bp is the fast native one" is false at depth. + // Only `pymatching` is faster (~6x) but it is an EXTERNAL dep with zero accuracy + // or threshold gain, so it is the documented speed option, not the default. + // SCOPE: the study families are graphlike, so it does not distinguish fusion + // from hyperedge decoders (tesseract/mwpf) -- re-run with those if non-graphlike + // per-observable DEMs (biased/correlated noise) ever arise. See + // pecos-docs/design/inner-decoder-threshold-study.md. + // + // `pecos_uf:bp` remains the pure-native, dependency-free path (it does suppress + // with distance -- the predecoder bug that broke it at d>=5 is fixed -- just at + // a worse prefactor and lower threshold). See + // pecos-docs/design/lomatching-paper-additional-learnings.md and + // logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:fast", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="fusion_blossom_serial", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, inner_decoder: &str, max_time_radius: Option, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; // Parse stab_coords from Python dicts let mut rust_stab_coords = Vec::with_capacity(stab_coords.len()); @@ -4462,7 +5616,7 @@ impl PyObservableSubgraphDecoder { }); } - let inner = ObservableSubgraphDecoder::from_dem_windowed( + let inner = LogicalSubgraphDecoder::from_dem_windowed( dem, &rust_stab_coords, max_time_radius, @@ -4476,15 +5630,55 @@ impl PyObservableSubgraphDecoder { ) .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(Self { inner }) + Ok(Self { + inner, + inner_decoder: inner_decoder.to_string(), + }) + } + + /// Build from a precomputed per-observable detector membership instead of + /// from `stab_coords`. + /// + /// `membership` is a list (one entry per observable) of full-DEM detector + /// ids. This lets callers supply an alternative observing-region + /// construction (e.g. the paper's back-propagation / detecting-region set) + /// and decode with the same machinery for direct comparison. + #[staticmethod] + #[pyo3(signature = (dem, membership, inner_decoder="fusion_blossom_serial"))] + fn from_membership( + dem: &str, + membership: Vec>, + inner_decoder: &str, + ) -> PyResult { + use pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder; + + let inner = LogicalSubgraphDecoder::from_membership(dem, &membership, |subgraph| { + let sub_dem = subgraph_to_dem_string(subgraph); + let decoder = create_observable_decoder(&sub_dem, inner_decoder) + .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; + Ok(Box::new(SendWrapper(decoder)) + as Box) + }) + .map_err(|e| PyErr::new::(e.to_string()))?; + + Ok(Self { + inner, + inner_decoder: inner_decoder.to_string(), + }) } /// Decode a syndrome and return observable flip predictions. - fn decode(&mut self, syndrome: Vec) -> PyResult { + /// + /// Returns a Python ``int`` (bit ``i`` = observable ``i``). The integer is + /// arbitrary precision, so decoders with more than 64 observables are + /// returned without truncation. + fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { use pecos_decoder_core::ObservableDecoder; - self.inner - .decode_to_observables(&syndrome) - .map_err(|e| PyErr::new::(e.to_string())) + let mask = self + .inner + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + obsmask_to_py(py, &mask) } /// Number of observables this decoder handles. @@ -4492,22 +5686,36 @@ impl PyObservableSubgraphDecoder { self.inner.num_observables() } + /// The inner per-observable decoder backend selected at construction. + /// + /// `decode_count_parallel` reuses this unless the caller overrides it, so + /// the serial and parallel paths agree by default. + #[getter] + fn inner_decoder(&self) -> &str { + &self.inner_decoder + } + /// Decode a batch of syndromes and return observable predictions. /// /// Args: /// syndromes: 2D numpy array of shape (`num_shots`, `num_detectors`). /// /// Returns: - /// List of observable flip masks (one per shot). - fn decode_batch(&mut self, syndromes: Vec>) -> PyResult> { + /// List of observable flip masks (one Python ``int`` per shot; arbitrary + /// precision, so more than 64 observables are not truncated). + fn decode_batch( + &mut self, + py: Python<'_>, + syndromes: Vec>, + ) -> PyResult>> { use pecos_decoder_core::ObservableDecoder; let mut results = Vec::with_capacity(syndromes.len()); for syn in &syndromes { - let obs = self + let mask = self .inner - .decode_to_observables(syn) + .decode_obs(syn) .map_err(|e| PyErr::new::(e.to_string()))?; - results.push(obs); + results.push(obsmask_to_py(py, &mask)?); } Ok(results) } @@ -4529,8 +5737,8 @@ impl PyObservableSubgraphDecoder { s }) .collect(); - let observable_masks: Vec = (0..batch.num_shots) - .map(|i| batch.extract_obs_mask(i)) + let observable_masks: Vec = (0..batch.num_shots) + .map(|i| batch.extract_obs_mask_wide(i)) .collect(); self.inner .decode_count_batched(&detection_events, &observable_masks) @@ -4540,18 +5748,20 @@ impl PyObservableSubgraphDecoder { /// Decode a `SampleBatch` in parallel using rayon. /// /// Creates per-worker decoder instances to avoid lock contention. - /// Requires the DEM string and inner decoder type for reconstruction. - #[pyo3(signature = (batch, dem, stab_coords, inner_decoder="pymatching", num_workers=None, max_time_radius=None))] + /// Requires the DEM string for reconstruction. `inner_decoder` defaults to + /// the backend selected at construction (so the parallel path matches the + /// serial `decode_count` path); pass an explicit value only to override it. + #[pyo3(signature = (batch, dem, stab_coords, inner_decoder=None, num_workers=None, max_time_radius=None))] fn decode_count_parallel( &self, batch: &PySampleBatch, dem: &str, stab_coords: Vec>, - inner_decoder: &str, + inner_decoder: Option<&str>, num_workers: Option, max_time_radius: Option, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; use rayon::prelude::*; // Parse stab_coords @@ -4572,7 +5782,11 @@ impl PyObservableSubgraphDecoder { } let dem_str = dem.to_string(); - let inner_str = inner_decoder.to_string(); + // Reuse the backend chosen at construction unless the caller overrides, + // so parallel workers decode identically to the serial path. + let inner_str = inner_decoder + .unwrap_or(self.inner_decoder.as_str()) + .to_string(); let n = batch.num_shots; // Materialize row-major data for parallel decode. @@ -4583,14 +5797,18 @@ impl PyObservableSubgraphDecoder { s }) .collect(); - let masks: Vec = (0..n).map(|i| batch.extract_obs_mask(i)).collect(); + let masks: Vec = + (0..n).map(|i| batch.extract_obs_mask_wide(i)).collect(); let pool = rayon::ThreadPoolBuilder::new() .num_threads(num_workers.unwrap_or(0)) .build() .map_err(|e| PyErr::new::(e.to_string()))?; - let errors: usize = pool.install(|| { + // Propagate worker construction and decode errors instead of panicking + // across the FFI boundary or silently scoring a failed chunk as + // all-failures (which would inflate the reported logical error rate). + let errors: Result = pool.install(|| { // Split into chunks, each chunk gets its own decoder + batch decode let chunk_size = n.div_ceil(rayon::current_num_threads()); (0..n) @@ -4598,7 +5816,7 @@ impl PyObservableSubgraphDecoder { .par_chunks(chunk_size.max(1)) .map(|chunk| { // Build a fresh decoder for this worker - let mut dec = ObservableSubgraphDecoder::from_dem_windowed( + let mut dec = LogicalSubgraphDecoder::from_dem_windowed( &dem_str, &sc, max_time_radius, @@ -4611,20 +5829,19 @@ impl PyObservableSubgraphDecoder { 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(); + let chunk_masks: Vec = + chunk.iter().map(|&i| masks[i].clone()).collect(); dec.decode_count_batched(&chunk_syns, &chunk_masks) - .unwrap_or(chunk.len()) }) - .sum() + .try_reduce(|| 0, |a, b| Ok(a + b)) }); - Ok(errors) + errors.map_err(|e| PyErr::new::(e.to_string())) } /// Number of detectors in each subgraph. @@ -4634,6 +5851,15 @@ impl PyObservableSubgraphDecoder { .collect() } + /// Per-observable observing regions: a list (one entry per observable) of + /// sorted full-DEM detector ids in that observable's subgraph. + /// + /// Exposed for differential testing against reference implementations such + /// as `lomatching.get_detector_indices_for_subgraphs`. + fn observing_regions(&self) -> Vec> { + self.inner.observing_regions() + } + /// Diagnostics: (`num_edges`, `skipped_hyperedges`) for each subgraph. fn subgraph_diagnostics(&self) -> Vec<(usize, usize)> { (0..self.inner.num_observables()) @@ -4655,7 +5881,7 @@ impl PyObservableSubgraphDecoder { stab_coords: Vec>, ) -> PyResult<(usize, usize)> { use pecos_decoder_core::ghost_protocol::extract_ghost_edges_from_dem; - use pecos_decoder_core::observable_subgraph::QubitStabCoords; + use pecos_decoder_core::logical_subgraph::QubitStabCoords; let mut sc = Vec::with_capacity(stab_coords.len()); for dict in &stab_coords { @@ -4678,10 +5904,15 @@ impl PyObservableSubgraphDecoder { Ok((edges.len(), num_qubits)) } - /// Get the per-subgraph DEM strings (graphlike, suitable for windowed decoding). + /// Get the per-subgraph DEM strings (graphlike, local detector IDs 0..N). /// - /// Each string is a DEM with local detector IDs (0..N) that can be - /// passed to windowed or sandwich decoders. + /// NOTE: these strings carry NO `detector(...)` coordinate lines (subgraph + /// graphs drop coordinates), so they are NOT suitable for *time-windowed* + /// decoding -- a windowed decoder would see no detector times and collapse to + /// a single window. For windowing, use the coord-preserving + /// `LogicalSubgraphWindowPlan` path (the `WindowedLogicalSubgraphDecoder` / + /// logical-circuit windowed budget already do). These strings are fine for + /// full (non-windowed) per-subgraph decoding. fn subgraph_dems(&self) -> Vec { (0..self.inner.num_observables()) .map(|i| { @@ -4705,38 +5936,41 @@ impl PyObservableSubgraphDecoder { } // ============================================================================= -// Windowed OSD Decoder (Python class) +// Windowed logical-subgraph decoding Decoder (Python class) // ============================================================================= /// Windowed observable subgraph decoder for deep circuits. /// -/// Splits the DEM into time windows, runs OSD within each window. +/// Splits the DEM into time windows, runs logical-subgraph decoder within each window. /// Prevents the observing region from spanning the full circuit. /// +/// Partitions the DEM per observable, then windows each subgraph with proper +/// sliding-window core-commit (only correction edges whose both endpoints lie +/// in a window's core are committed). The inner decoder is the native +/// edge-tracking union-find decoder, which core-commit requires. +/// /// Args: /// dem: DEM string. /// `stab_coords`: Stabilizer coordinates per logical qubit. -/// `inner_decoder`: Inner MWPM decoder type. /// step: Core window size in time steps. -/// buffer: Buffer size on each side (0 = non-overlapping). -#[pyclass(name = "WindowedOsdDecoder", module = "pecos_rslib.qec")] -pub struct PyWindowedOsdDecoder { - inner: pecos_decoder_core::windowed_osd::WindowedOsdDecoder, +/// buffer: Buffer size on each side for matching context (0 = +/// non-overlapping; recommend ~code distance). +#[pyclass(name = "WindowedLogicalSubgraphDecoder", module = "pecos_rslib.qec")] +pub struct PyWindowedLogicalSubgraphDecoder { + inner: pecos_decoders::WindowedLogicalSubgraphDecoder, } #[pymethods] -impl PyWindowedOsdDecoder { +impl PyWindowedLogicalSubgraphDecoder { #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pymatching", step=8, buffer=4))] + #[pyo3(signature = (dem, stab_coords, step=8, buffer=4))] fn new( dem: &str, stab_coords: Vec>, - inner_decoder: &str, step: usize, buffer: usize, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::QubitStabCoords; - use pecos_decoder_core::windowed_osd::{WindowedOsdConfig, WindowedOsdDecoder}; + use pecos_decoder_core::logical_subgraph::QubitStabCoords; let mut sc = Vec::with_capacity(stab_coords.len()); for dict in &stab_coords { @@ -4754,25 +5988,26 @@ impl PyWindowedOsdDecoder { }); } - let config = WindowedOsdConfig { step, buffer }; + let config = pecos_decoders::WindowedConfig { + step_size: step, + buffer_size: buffer, + ..Default::default() + }; - 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()))?; + let inner = + pecos_decoders::WindowedLogicalSubgraphDecoder::from_dem(dem, &sc, None, config) + .map_err(|e| PyErr::new::(e.to_string()))?; Ok(Self { inner }) } - fn decode(&mut self, syndrome: Vec) -> PyResult { + fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { use pecos_decoder_core::ObservableDecoder; - self.inner - .decode_to_observables(&syndrome) - .map_err(|e| PyErr::new::(e.to_string())) + let mask = self + .inner + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + obsmask_to_py(py, &mask) } fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { @@ -4783,9 +6018,9 @@ impl PyWindowedOsdDecoder { batch.extract_syndrome(i, &mut syndrome); let predicted = self .inner - .decode_to_observables(&syndrome) + .decode_obs(&syndrome) .map_err(|e| PyErr::new::(e.to_string()))?; - if predicted != batch.extract_obs_mask(i) { + if predicted != batch.extract_obs_mask_wide(i) { errors += 1; } } @@ -4793,7 +6028,7 @@ impl PyWindowedOsdDecoder { } fn num_windows(&self) -> usize { - self.inner.windows.len() + self.inner.num_windows() } } @@ -4801,7 +6036,33 @@ impl PyWindowedOsdDecoder { // Logical Algorithm Decoder (Python class) // ============================================================================= -/// Decoder for logical quantum algorithms with per-segment OSD and +/// Read a required `u32` bit field off a boundary-gate descriptor dict, returning +/// a clear `PyErr` (not a panic) when a malformed descriptor omits the field. +/// Shared by the two algorithm-decoder bindings below. +fn req_bit( + dict: &pyo3::Bound<'_, pyo3::types::PyDict>, + key: &str, + gate_type: &str, +) -> PyResult { + let bit: u32 = dict + .get_item(key)? + .ok_or_else(|| { + PyErr::new::(format!( + "boundary gate '{gate_type}' missing required field '{key}'" + )) + })? + .extract()?; + // Every boundary-gate bit indexes a u64 observable frame (`1u64 << bit`), so + // it must be < 64 -- reject out-of-range here rather than shift-overflow later. + if bit >= 64 { + return Err(PyErr::new::(format!( + "boundary gate '{gate_type}' field '{key}' = {bit} exceeds the 64-observable frame limit" + ))); + } + Ok(bit) +} + +/// Decoder for logical quantum algorithms with per-segment logical-subgraph decoder and /// Pauli frame propagation at transversal gate boundaries. /// /// Built from a descriptor dict produced by @@ -4820,7 +6081,7 @@ impl PyLogicalAlgorithmDecoder { /// /// Args: /// descriptor: Dict from ``LogicalCircuitBuilder.build_algorithm_descriptor()``. - /// `inner_decoder`: Decoder type string for each segment's OSD inner decoder. + /// `inner_decoder`: Decoder type string for each segment's logical-subgraph decoder inner decoder. #[new] #[pyo3(signature = (descriptor, inner_decoder="pymatching"))] fn new( @@ -4830,9 +6091,9 @@ impl PyLogicalAlgorithmDecoder { use pecos_decoder_core::logical_algorithm::{ AlgorithmDescriptor, BoundaryGate, LogicalAlgorithmDecoder, SegmentDescriptor, }; - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; - // Parse full DEM and stab_coords for full-circuit OSD + // Parse full DEM and stab_coords for full-circuit logical-subgraph decoder let full_dem: String = descriptor .get_item("full_dem")? .ok_or_else(|| PyErr::new::("full_dem"))? @@ -4851,7 +6112,9 @@ impl PyLogicalAlgorithmDecoder { .extract()?; // Parse stab_coords from the first segment (original orientation) - let first_seg = &seg_list[0]; + let first_seg = seg_list.first().ok_or_else(|| { + PyErr::new::("algorithm descriptor has no segments") + })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? .ok_or_else(|| PyErr::new::("stab_coords"))? @@ -4874,8 +6137,8 @@ impl PyLogicalAlgorithmDecoder { let inner_str = inner_decoder.to_string(); - // Build full-circuit OSD from the full DEM - let full_osd = ObservableSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { + // Build full-circuit logical-subgraph decoder from the full DEM + let full_osd = LogicalSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { let sub_dem = subgraph_to_dem_string(subgraph); let d = create_observable_decoder(&sub_dem, &inner_str) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; @@ -4913,32 +6176,28 @@ impl PyLogicalAlgorithmDecoder { .extract()?; match gate_type.as_str() { "Hadamard" => { - let x: u32 = gate_dict.get_item("x_obs_bit")?.unwrap().extract()?; - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; bg_vec.push(BoundaryGate::Hadamard { - x_obs_bit: x, - z_obs_bit: z, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "Cnot" => { bg_vec.push(BoundaryGate::Cnot { - ctrl_x_bit: gate_dict.get_item("ctrl_x_bit")?.unwrap().extract()?, - ctrl_z_bit: gate_dict.get_item("ctrl_z_bit")?.unwrap().extract()?, - tgt_x_bit: gate_dict.get_item("tgt_x_bit")?.unwrap().extract()?, - tgt_z_bit: gate_dict.get_item("tgt_z_bit")?.unwrap().extract()?, + ctrl_x_bit: req_bit(gate_dict, "ctrl_x_bit", &gate_type)?, + ctrl_z_bit: req_bit(gate_dict, "ctrl_z_bit", &gate_type)?, + tgt_x_bit: req_bit(gate_dict, "tgt_x_bit", &gate_type)?, + tgt_z_bit: req_bit(gate_dict, "tgt_z_bit", &gate_type)?, }); } "SGate" => { - let x: u32 = gate_dict.get_item("x_obs_bit")?.unwrap().extract()?; - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; bg_vec.push(BoundaryGate::SGate { - x_obs_bit: x, - z_obs_bit: z, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "TGateInjection" => { - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; - let a: u32 = gate_dict.get_item("ancilla_z_bit")?.unwrap().extract()?; + let z = req_bit(gate_dict, "z_obs_bit", &gate_type)?; + let a = req_bit(gate_dict, "ancilla_z_bit", &gate_type)?; bg_vec.push(BoundaryGate::TGateInjection { z_obs_bit: z, ancilla_z_bit: a, @@ -4967,32 +6226,33 @@ impl PyLogicalAlgorithmDecoder { // -- Batch mode -- - /// Decode a single syndrome and return observable flip mask. - fn decode(&mut self, syndrome: Vec) -> PyResult { + /// Decode a single syndrome and return the observable flip mask as a Python + /// ``int`` (arbitrary precision; more than 64 observables are not truncated). + fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { self.inner.reset(); - self.inner - .decode_shot(&syndrome) - .map_err(|e| PyErr::new::(e.to_string())) + let mask = self + .inner + .decode_shot_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + obsmask_to_py(py, &mask) } - /// Decode a batch of samples and count logical errors. + /// Decode a batch of samples and count logical errors (wide observable masks). fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { - let detection_events: Vec> = (0..batch.num_shots) - .map(|i| { - let mut s = vec![0u8; batch.num_detectors]; - batch.extract_syndrome(i, &mut s); - s - }) - .collect(); - let observable_masks: Vec = (0..batch.num_shots) - .map(|i| batch.extract_obs_mask(i)) - .collect(); - pecos_decoder_core::logical_algorithm::streaming_decode_count( - &mut self.inner, - &detection_events, - &observable_masks, - ) - .map_err(|e| PyErr::new::(e.to_string())) + let mut errors = 0usize; + let mut syndrome = vec![0u8; batch.num_detectors]; + for i in 0..batch.num_shots { + batch.extract_syndrome(i, &mut syndrome); + self.inner.reset(); + let predicted = self + .inner + .decode_shot_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + if predicted != batch.extract_obs_mask_wide(i) { + errors += 1; + } + } + Ok(errors) } // -- Streaming mode -- @@ -5044,8 +6304,8 @@ impl PyLogicalAlgorithmDecoder { /// Budget-aware decoder for logical quantum circuits. /// /// Selects decode strategy based on available reaction time: -/// - ``"unlimited"``: full-circuit OSD (Clifford circuits, offline) -/// - ``"windowed"``: default windowed OSD (~1ms reaction time) +/// - ``"unlimited"``: full-circuit logical-subgraph decoder (Clifford circuits, offline) +/// - ``"windowed"``: default windowed logical-subgraph decoder (~1ms reaction time) /// - ``"10ms"``, ``"1000us"``, etc.: explicit reaction time budget /// /// The reaction time is the time available at feed-forward decision @@ -5060,23 +6320,34 @@ impl PyLogicalAlgorithmDecoder { #[pyclass(name = "LogicalCircuitDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalCircuitDecoder { inner: pecos_decoder_core::logical_algorithm::LogicalCircuitDecoder, + /// How the decode actually windows: "unlimited" (full circuit), + /// "full_fallback" (per-observable full decode behind a windowed budget), + /// or "real_windowed" (genuine sliding-window; not yet enabled). + effective_windowing: String, + /// Window count actually used, one entry per non-empty subgraph (1 == full + /// decode); not indexed by global observable id. + actual_num_windows: Vec, + /// Whether genuine time-windowing is *possible* for this circuit (deep + /// enough), independent of whether it is enabled. False for "unlimited". + can_window: bool, } #[pymethods] impl PyLogicalCircuitDecoder { #[new] - #[pyo3(signature = (descriptor, budget="unlimited", inner_decoder="pymatching"))] + #[pyo3(signature = (descriptor, budget="unlimited", inner_decoder="pymatching", strict=false))] fn new( descriptor: &pyo3::Bound<'_, pyo3::types::PyDict>, budget: &str, inner_decoder: &str, + strict: bool, ) -> PyResult { use pecos_decoder_core::decode_budget::DecodeBudget; use pecos_decoder_core::logical_algorithm::{ AlgorithmDescriptor, BoundaryGate, FullCircuitStrategy, LogicalCircuitDecoder, SegmentDescriptor, }; - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; // Parse full DEM let full_dem: String = descriptor @@ -5095,7 +6366,9 @@ impl PyLogicalCircuitDecoder { .extract()?; // Parse stab_coords from first segment - let first_seg = &seg_list[0]; + let first_seg = seg_list.first().ok_or_else(|| { + PyErr::new::("algorithm descriptor has no segments") + })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? .ok_or_else(|| PyErr::new::("stab_coords"))? @@ -5118,7 +6391,7 @@ impl PyLogicalCircuitDecoder { let num_qubits = rust_sc.len(); let inner_str = inner_decoder.to_string(); - let full_osd = ObservableSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { + let full_osd = LogicalSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { let sub_dem = subgraph_to_dem_string(subgraph); let d = create_observable_decoder(&sub_dem, &inner_str) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; @@ -5157,27 +6430,27 @@ impl PyLogicalCircuitDecoder { match gate_type.as_str() { "Hadamard" => { bg_vec.push(BoundaryGate::Hadamard { - x_obs_bit: gate_dict.get_item("x_obs_bit")?.unwrap().extract()?, - z_obs_bit: gate_dict.get_item("z_obs_bit")?.unwrap().extract()?, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "Cnot" => { bg_vec.push(BoundaryGate::Cnot { - ctrl_x_bit: gate_dict.get_item("ctrl_x_bit")?.unwrap().extract()?, - ctrl_z_bit: gate_dict.get_item("ctrl_z_bit")?.unwrap().extract()?, - tgt_x_bit: gate_dict.get_item("tgt_x_bit")?.unwrap().extract()?, - tgt_z_bit: gate_dict.get_item("tgt_z_bit")?.unwrap().extract()?, + ctrl_x_bit: req_bit(gate_dict, "ctrl_x_bit", &gate_type)?, + ctrl_z_bit: req_bit(gate_dict, "ctrl_z_bit", &gate_type)?, + tgt_x_bit: req_bit(gate_dict, "tgt_x_bit", &gate_type)?, + tgt_z_bit: req_bit(gate_dict, "tgt_z_bit", &gate_type)?, }); } "SGate" => { bg_vec.push(BoundaryGate::SGate { - x_obs_bit: gate_dict.get_item("x_obs_bit")?.unwrap().extract()?, - z_obs_bit: gate_dict.get_item("z_obs_bit")?.unwrap().extract()?, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "TGateInjection" => { - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; - let a: u32 = gate_dict.get_item("ancilla_z_bit")?.unwrap().extract()?; + let z = req_bit(gate_dict, "z_obs_bit", &gate_type)?; + let a = req_bit(gate_dict, "ancilla_z_bit", &gate_type)?; bg_vec.push(BoundaryGate::TGateInjection { z_obs_bit: z, ancilla_z_bit: a, @@ -5201,10 +6474,24 @@ impl PyLogicalCircuitDecoder { // Select budget: "unlimited" for full-circuit, "windowed" for // bounded-latency, or a cycle time in microseconds like "1000us". - let mut distance = 0usize; - while distance.saturating_mul(distance) < num_qubits { - distance += 1; - } + // + // Use the REAL physical code distance from the descriptor (used for the + // windowing step / latency bound). `num_qubits = rust_sc.len()` is the + // number of logical patches, NOT a distance -- deriving distance from it + // (e.g. sqrt) is wrong (a single d=7 patch would yield distance 1 and + // make `can_window`/`strict` dishonest). Fall back to the old patch-count + // heuristic only for legacy descriptors that predate the `distance` field. + let distance: usize = descriptor + .get_item("distance")? + .and_then(|v| v.extract::().ok()) + .filter(|&d| d > 0) + .unwrap_or_else(|| { + let mut d = 0usize; + while d.saturating_mul(d) < num_qubits { + d += 1; + } + d.max(1) + }); let decode_budget = match budget { "unlimited" | "offline" => DecodeBudget::unlimited(), "windowed" => { @@ -5234,72 +6521,144 @@ impl PyLogicalCircuitDecoder { }; // Select strategy based on budget. + let mut effective_windowing = String::from("unlimited"); + let mut actual_num_windows: Vec = Vec::new(); + let mut can_window = false; let strategy: Box = if decode_budget.is_unlimited() { - // Unlimited: full-circuit OSD (maximum accuracy) + // Unlimited: full-circuit logical-subgraph decoder (maximum accuracy) Box::new(FullCircuitStrategy::new(Box::new(full_osd))) } else { - // Windowed: per-subgraph sandwich decoding. - // Extract per-subgraph DEMs and detector maps from the full OSD. - use pecos_decoder_core::logical_algorithm::WindowedOsdStrategy; - - 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()); - } + // A bounded-latency ("windowed") budget was requested. Genuine + // per-observable sliding-window LOM decoding does not yet + // suppress (the windowed-LOM time-like-snake limitation; needs + // the anti-snake machinery), so we do an EXPLICIT full-decode + // fallback per observable -- accurate, but NOT bounded latency -- + // and surface that honestly via `effective_windowing()` / + // `actual_num_windows()`. No silent fallback. `strict=True` turns + // the unmet latency budget into a hard error. + use pecos_decoder_core::logical_algorithm::WindowedLogicalSubgraphStrategy; + use pecos_decoder_core::logical_subgraph::window_plan::EffectiveWindowing; + + // Coord-preserving window plan (reports whether real windowing is + // even possible for this circuit depth). + let full_coords = pecos_decoder_core::DemMatchingGraph::from_dem_str(&full_dem) + .map_err(|e| PyErr::new::(e.to_string()))? + .detector_coords; + let plan = full_osd.window_plan(&full_coords); + let step = decode_budget.code_distance.max(1); + can_window = plan.effective_windowing(step) == EffectiveWindowing::RealWindowed; + + // `strict` rejects only when genuine windowing was POSSIBLE (the + // circuit is deep enough) but is being skipped. When `!can_window` + // the circuit is a single window anyway, so a full decode IS the + // bounded-latency answer -- no degradation to reject. + if strict && can_window { + return Err(PyErr::new::( + "bounded-latency ('windowed') budget requested with strict=True, \ + but accurate windowed logical-subgraph decoding is not yet \ + available (windowed-LOM anti-snake machinery pending). This \ + circuit is deep enough to time-window (can_window=True), so a \ + full per-observable decode would forgo the requested latency \ + bound. Use budget='unlimited', or pass strict=False to accept \ + the full-decode fallback." + .to_string(), + )); } - let 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) - }) + let sub_dems = plan.sub_dems(); + let det_maps = plan.detector_maps(); + let obs_indices: Vec = + plan.entries().iter().map(|e| e.observable_idx).collect(); + // The fallback runs a full (non-windowed) inner per observable, so + // the actual window count is 1 each by construction. (The Layer C + // real-windowed path must instead derive these from the windowed + // inners.) The label is single-sourced from the plan's enum. + effective_windowing = EffectiveWindowing::FullFallback.as_str().to_string(); + actual_num_windows = vec![1usize; sub_dems.len()]; + + let fallback_inner = inner_decoder.to_string(); + let wosd = WindowedLogicalSubgraphStrategy::new( + sub_dems, + det_maps, + obs_indices, + |dem_str| { + let dec = + create_observable_decoder(dem_str, &fallback_inner).map_err(|e| { + pecos_decoders::DecoderError::InternalError(e.to_string()) + })?; + Ok(Box::new(SendWrapper(dec)) + as Box) + }, + ) .map_err(|e| PyErr::new::(e.to_string()))?; Box::new(wosd) }; let inner = LogicalCircuitDecoder::new(algo_desc, strategy, decode_budget, num_qubits); - Ok(Self { inner }) + Ok(Self { + inner, + effective_windowing, + actual_num_windows, + can_window, + }) + } + + /// How the decode actually windows: ``"unlimited"`` (full-circuit decode), + /// ``"full_fallback"`` (per-observable full decode behind a windowed + /// budget -- accurate but NOT bounded latency), or ``"real_windowed"`` + /// (genuine sliding-window; not yet enabled pending the windowed-LOM + /// anti-snake machinery). Lets callers/tests assert the effective mode + /// instead of trusting a silent fallback. + #[getter] + fn effective_windowing(&self) -> &str { + &self.effective_windowing } - /// Decode a single syndrome. - fn decode(&mut self, syndrome: Vec) -> PyResult { + /// Window count actually used, one entry per *non-empty* subgraph in + /// surviving-subgraph order (empty-region observables are dropped, so this + /// is not indexed by global observable id). ``1`` == full decode. All ``1`` + /// in the current full-fallback path; empty for the unlimited budget. + #[getter] + fn actual_num_windows(&self) -> Vec { + self.actual_num_windows.clone() + } + + /// Whether genuine time-windowing is *possible* for this circuit (deep + /// enough), independent of whether it is enabled. ``False`` for unlimited. + #[getter] + fn can_window(&self) -> bool { + self.can_window + } + + /// Decode a single syndrome. Returns a Python ``int`` (arbitrary precision; + /// more than 64 observables are not truncated). + fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { use pecos_decoder_core::ObservableDecoder; - self.inner - .decode_to_observables(&syndrome) - .map_err(|e| PyErr::new::(e.to_string())) + let mask = self + .inner + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + obsmask_to_py(py, &mask) } - /// Decode a batch and count errors. + /// Decode a batch and count errors (wide observable masks). fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { - let detection_events: Vec> = (0..batch.num_shots) - .map(|i| { - let mut s = vec![0u8; batch.num_detectors]; - batch.extract_syndrome(i, &mut s); - s - }) - .collect(); - let observable_masks: Vec = (0..batch.num_shots) - .map(|i| batch.extract_obs_mask(i)) - .collect(); - self.inner - .decode_count(&detection_events, &observable_masks) - .map_err(|e| PyErr::new::(e.to_string())) + use pecos_decoder_core::ObservableDecoder; + let mut errors = 0usize; + let mut syndrome = vec![0u8; batch.num_detectors]; + for i in 0..batch.num_shots { + batch.extract_syndrome(i, &mut syndrome); + let predicted = self + .inner + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + if predicted != batch.extract_obs_mask_wide(i) { + errors += 1; + } + } + Ok(errors) } /// Number of segments. @@ -5525,6 +6884,7 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { let base = decoder_type.split(':').next().unwrap_or(decoder_type); match base { "pymatching" + | "pymatching_correlated" | "pymatching_uncorrelated" | "fusion_blossom" | "fusion_blossom_serial" @@ -5557,12 +6917,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::()?; diff --git a/python/pecos-rslib/src/hugr_compilation_bindings.rs b/python/pecos-rslib/src/hugr_compilation_bindings.rs index 697f3c520..d3da7798a 100644 --- a/python/pecos-rslib/src/hugr_compilation_bindings.rs +++ b/python/pecos-rslib/src/hugr_compilation_bindings.rs @@ -5,6 +5,17 @@ use std::fs; use pyo3::prelude::*; use pyo3::types::PyDict; +/// Map a platform name to a [`QSystemPlatform`], failing loudly on unknown input. +fn parse_qsystem_platform(name: &str) -> PyResult { + match name.to_ascii_lowercase().as_str() { + "helios" => Ok(QSystemPlatform::Helios), + "sol" => Ok(QSystemPlatform::Sol), + other => Err(PyErr::new::(format!( + "Unknown QSystem platform {other:?}; expected 'helios' or 'sol'" + ))), + } +} + /// Compile HUGR to QIS (LLVM IR with quantum instructions) /// /// This function takes HUGR bytes (envelope format) and compiles them to QIS, @@ -13,12 +24,22 @@ use pyo3::types::PyDict; /// Args: /// `hugr_bytes`: HUGR program as envelope bytes /// `output_path`: Optional path to write the QIS output +/// `platform`: Target `QSystem` platform, `'helios'` (default) or `'sol'` /// /// Returns: /// QIS (LLVM IR) as a string -#[pyfunction(name = "compile_hugr_to_qis", signature = (hugr_bytes, output_path=None))] -pub fn py_compile_hugr_to_qis(hugr_bytes: &[u8], output_path: Option<&str>) -> PyResult { - let llvm_ir = compile_hugr_bytes_to_string(hugr_bytes) +#[pyfunction(name = "compile_hugr_to_qis", signature = (hugr_bytes, output_path=None, platform=None))] +pub fn py_compile_hugr_to_qis( + hugr_bytes: &[u8], + output_path: Option<&str>, + platform: Option<&str>, +) -> PyResult { + let mut args = CompileArgs::default(); + if let Some(name) = platform { + args.platform = parse_qsystem_platform(name)?; + } + + let llvm_ir = compile_hugr_bytes_to_string_with_options(hugr_bytes, &args) .map_err(|e| PyErr::new::(e.to_string()))?; if let Some(path) = output_path { @@ -48,6 +69,7 @@ pub fn get_compilation_backends(py: Python<'_>) -> PyResult> { backends.set_item("hugr-llvm", hugr_llvm_backend)?; result.set_item("backends", backends)?; + result.set_item("qsystem_platforms", vec!["helios", "sol"])?; Ok(result.into()) } diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index f22d4f96d..c01231733 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -102,11 +102,12 @@ use wasm_foreign_object_bindings::PyWasmForeignObject; /// Find an LLVM tool by name (e.g., "llvm-as", "llc", "opt"). /// -/// This searches for the tool in the LLVM 14 installation using the same +/// This searches for the tool in the LLVM 21.1 installation using the same /// logic as the pecos-build crate: -/// 1. ~/.pecos/llvm/ (PECOS managed installation) -/// 2. Project-local llvm/ directory -/// 3. System installations (Homebrew on macOS, package manager on Linux) +/// 1. ~/.pecos/deps/llvm-21.1/ (PECOS-managed installation where supported) +/// 2. ~/.pecos/llvm/ (legacy path) +/// 3. Project-local llvm/ directory +/// 4. System installations (Homebrew on macOS, package manager on Linux) /// /// Returns None if the tool is not found. #[pyfunction] @@ -187,7 +188,7 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { setup_cuda_library_path(); // CRITICAL: Preload libselene_simple_runtime.so with RTLD_GLOBAL BEFORE anything else - // This prevents conflicts with LLVM-14 when the Selene runtime is loaded later + // This prevents conflicts with LLVM-21.1 when the Selene runtime is loaded later #[cfg(unix)] { use std::ffi::CString; diff --git a/python/pecos-rslib/src/namespace_modules.rs b/python/pecos-rslib/src/namespace_modules.rs index 865d6d718..83574b7a3 100644 --- a/python/pecos-rslib/src/namespace_modules.rs +++ b/python/pecos-rslib/src/namespace_modules.rs @@ -20,6 +20,10 @@ pub fn register_quantum_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { quantum.add("TickHandle", parent.getattr("TickHandle")?)?; quantum.add("TickPrepHandle", parent.getattr("TickPrepHandle")?)?; quantum.add("TickMeasureHandle", parent.getattr("TickMeasureHandle")?)?; + quantum.add( + "PHYSICAL_DURATION_META_KEY", + parent.getattr("PHYSICAL_DURATION_META_KEY")?, + )?; quantum.add( "DagCircuitWouldCycleError", parent.getattr("DagCircuitWouldCycleError")?, diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 2228fa4be..4413c85a2 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -10,7 +10,7 @@ use crate::prelude::*; // Import QASM WASM support use pecos_qasm::QasmEngineWasm; -use pyo3::exceptions::{PyRuntimeError, PyTypeError}; +use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; use pyo3::prelude::*; use std::sync::{Arc, Mutex}; @@ -102,10 +102,13 @@ pub fn sim(py: Python, program: Py) -> PyResult { engine_builder: Arc::new(Mutex::new(Some(engine_builder))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, foreign_object: None, + stack: None, + classical_override: false, }), }) } else if let Ok(qis_prog) = program.extract::(py) { @@ -146,6 +149,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { engine_builder: Arc::new(Mutex::new(Some(engine_builder))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -171,12 +175,14 @@ pub fn sim(py: Python, program: Py) -> PyResult { engine_builder: Arc::new(Mutex::new(Some(engine_builder))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, foreign_object: None, keep_intermediate_files: false, hugr_bytes: Some(hugr_bytes), + stack: None, }), }) } else if let Ok(phir_prog) = program.extract::(py) { @@ -187,6 +193,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { engine_builder: Arc::new(Mutex::new(Some(engine_builder))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -210,6 +217,14 @@ pub fn sim_builder() -> PySimBuilder { } } +/// Which simulation stack `run()` uses, mirroring the Rust facade's +/// `pecos::SimStack`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum PySimStack { + Engines, + Neo, +} + /// Python simulation builder /// /// This builder follows the same fluent API as the Rust `SimBuilder`, @@ -256,6 +271,7 @@ impl PySimBuilder { drop(existing_engine_lock); sim_builder.engine_builder = Arc::new(Mutex::new(Some(qasm_engine.inner))); + sim_builder.classical_override = true; Ok(PySimBuilder { inner: self.inner.clone(), }) @@ -323,6 +339,7 @@ impl PySimBuilder { engine_builder: Arc::new(Mutex::new(Some(qis_engine))), seed: sim_builder.seed, workers: sim_builder.workers, + shots: sim_builder.shots, quantum_engine_builder: clone_py_any_option( py, sim_builder.quantum_engine_builder.as_ref(), @@ -380,6 +397,43 @@ impl PySimBuilder { }) } + /// Select the simulation stack: "engines" (the default) or "neo" + /// (experimental), mirroring the Rust facade's `.stack(SimStack)`. + fn stack(&mut self, stack: &str) -> PyResult { + let parsed = match stack { + "engines" => PySimStack::Engines, + "neo" => PySimStack::Neo, + other => { + return Err(PyValueError::new_err(format!( + "Unknown simulation stack '{other}'; expected \"engines\" or \"neo\"" + ))); + } + }; + match &mut self.inner { + SimBuilderInner::Qasm(builder) => builder.stack = Some(parsed), + SimBuilderInner::Hugr(builder) => builder.stack = Some(parsed), + SimBuilderInner::QisControl(_) + | SimBuilderInner::PhirJson(_) + | SimBuilderInner::Phir(_) => { + if parsed == PySimStack::Neo { + return Err(PyValueError::new_err( + "Only QASM and HUGR programs are routed to the neo stack so far; \ + this program type runs on the engines stack", + )); + } + // "engines" is already the default for every program type. + } + SimBuilderInner::Empty => { + return Err(PyTypeError::new_err( + "Cannot select a stack on an empty builder - create with a program first", + )); + } + } + Ok(PySimBuilder { + inner: self.inner.clone(), + }) + } + /// Set number of worker threads fn workers(&mut self, workers: usize) -> PyResult { match &mut self.inner { @@ -401,6 +455,25 @@ impl PySimBuilder { self.workers(workers) } + /// Set the number of Monte Carlo shots to run. + /// + /// Mirrors the Rust facade's `.shots(n)`: configure the shot count on the + /// builder, then call `.run()` with no argument. The legacy `.run(shots)` + /// still works and, when given, overrides this. + fn shots(&mut self, shots: usize) -> PyResult { + match &mut self.inner { + SimBuilderInner::Qasm(builder) => builder.shots = Some(shots), + SimBuilderInner::QisControl(builder) => builder.shots = Some(shots), + SimBuilderInner::Hugr(builder) => builder.shots = Some(shots), + SimBuilderInner::PhirJson(builder) => builder.shots = Some(shots), + SimBuilderInner::Phir(builder) => builder.shots = Some(shots), + SimBuilderInner::Empty => {} + } + Ok(PySimBuilder { + inner: self.inner.clone(), + }) + } + /// Set quantum simulator/engine fn quantum(&mut self, engine: Py) -> PyResult { match &mut self.inner { @@ -646,7 +719,10 @@ impl PySimBuilder { )); } } else { - sim_builder + return Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )); }; } @@ -663,7 +739,10 @@ impl PySimBuilder { { sim_builder.noise(biased.inner.clone()) } else { - sim_builder + return Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )); }; } @@ -690,9 +769,14 @@ impl PySimBuilder { } } - /// Run the simulation + /// Run the simulation. + /// + /// `shots` may be passed here (`run(1000)`) or configured on the builder + /// first (`.shots(1000).run()`); a `run()` argument overrides `.shots(n)`. + /// With neither set, this fails fast rather than defaulting silently. + #[pyo3(signature = (shots=None))] #[allow(clippy::too_many_lines)] // Complex simulation dispatch with multiple engine types - fn run(&self, shots: usize) -> PyResult { + fn run(&self, shots: Option) -> PyResult { use crate::engine_builders::{ PyBiasedDepolarizingNoiseModelBuilder, PyDepolarizingNoiseModelBuilder, PyGeneralNoiseModelBuilder, @@ -704,132 +788,27 @@ impl PySimBuilder { use crate::shot_results_bindings::PyShotVec; use pyo3::exceptions::PyRuntimeError; + // Resolve the shot count: an explicit `run(shots)` argument wins, then + // the builder's `.shots(n)`, else fail fast (no silent default). + let configured = match &self.inner { + SimBuilderInner::Qasm(b) => b.shots, + SimBuilderInner::QisControl(b) => b.shots, + SimBuilderInner::Hugr(b) => b.shots, + SimBuilderInner::PhirJson(b) => b.shots, + SimBuilderInner::Phir(b) => b.shots, + SimBuilderInner::Empty => None, + }; + let shots = shots.or(configured).ok_or_else(|| { + PyValueError::new_err( + "No shot count configured; pass run(shots) or set .shots(n) before .run(). \ + Example: sim(program).shots(1000).run()", + ) + })?; + log::debug!("PySimBuilder::run() called with {shots} shots"); match &self.inner { - SimBuilderInner::Qasm(builder) => { - let mut builder_lock = builder.engine_builder.lock().expect("lock poisoned"); - let engine_builder = builder_lock - .take() - .ok_or_else(|| PyRuntimeError::new_err("Builder already consumed"))?; - - // Apply foreign object if present - let engine_builder = if let Some(ref fo_py) = builder.foreign_object { - Python::attach(|py| -> PyResult<_> { - let fo_bound = fo_py.bind(py); - let wasm_obj: PyRef<'_, PyWasmForeignObject> = - fo_bound.cast::()?.borrow(); - // Get WASM bytes and create QasmEngineWasm - let wasm_bytes = wasm_obj.inner.wasm_bytes().to_vec(); - let qasm_wasm = QasmEngineWasm::from_bytes(wasm_bytes); - Ok(engine_builder.wasm(qasm_wasm)) - })? - } else { - engine_builder - }; - - // Create the Rust SimBuilder - let mut sim_builder = engine_builder.to_sim(); - - // Apply configuration - if let Some(seed) = builder.seed { - sim_builder = sim_builder.seed(seed); - } - if let Some(workers) = builder.workers { - sim_builder = sim_builder.workers(workers); - } - if let Some(n) = builder.explicit_num_qubits { - sim_builder = sim_builder.qubits(n); - } - - // Apply quantum engine builder if present - if let Some(ref qe_py) = builder.quantum_engine_builder { - sim_builder = Python::attach(|py| -> PyResult<_> { - if let Ok(mut state_vec) = qe_py.extract::(py) { - if let Some(inner) = state_vec.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut sparse_stab) = - qe_py.extract::(py) - { - if let Some(inner) = sparse_stab.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut stab_vec) = qe_py.extract::(py) - { - if let Some(inner) = stab_vec.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut density_mat) = - qe_py.extract::(py) - { - if let Some(inner) = density_mat.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut stab) = qe_py.extract::(py) - { - if let Some(inner) = stab.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut ct) = qe_py.extract::(py) { - if let Some(inner) = ct.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else { - Ok(sim_builder) - } - })?; - } - - // Apply noise builder if present - if let Some(ref noise_py) = builder.noise_builder { - sim_builder = Python::attach(|py| -> PyResult<_> { - if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) - } else if let Ok(depolarizing) = - noise_py.extract::(py) - { - Ok(sim_builder.noise(depolarizing.inner.clone())) - } else if let Ok(biased) = - noise_py.extract::(py) - { - Ok(sim_builder.noise(biased.inner.clone())) - } else { - Ok(sim_builder) - } - })?; - } - - // Run directly - match sim_builder.run(shots) { - Ok(shot_vec) => Ok(PyShotVec::new(shot_vec)), - Err(e) => Err(PyRuntimeError::new_err(format!("Simulation failed: {e}"))), - } - } + SimBuilderInner::Qasm(builder) => run_qasm_via_facade(builder, shots), SimBuilderInner::QisControl(builder) => { // Implementation for QIS Engine let mut builder_lock = builder.engine_builder.lock().expect("lock poisoned"); @@ -923,7 +902,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -942,7 +924,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1002,6 +987,9 @@ impl PySimBuilder { } } SimBuilderInner::Hugr(builder) => { + if builder.stack == Some(PySimStack::Neo) { + return run_hugr_neo(builder, shots); + } // Direct HUGR interpreter let mut builder_lock = builder.engine_builder.lock().expect("lock poisoned"); let engine_builder = builder_lock @@ -1090,7 +1078,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -1109,7 +1100,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1139,6 +1133,18 @@ impl PySimBuilder { use crate::engine_builders::{PyPhirJsonSimulation, PyPhirSimulation, PyQasmSimulation}; use pyo3::exceptions::PyRuntimeError; + let neo_selected = match &self.inner { + SimBuilderInner::Qasm(builder) => builder.stack == Some(PySimStack::Neo), + SimBuilderInner::Hugr(builder) => builder.stack == Some(PySimStack::Neo), + _ => false, + }; + if neo_selected { + return Err(PyRuntimeError::new_err( + "build() is not available on the neo stack (it has no reusable \ + MonteCarloEngine); call run(shots) directly or use the engines stack", + )); + } + Python::attach(|py| { match &self.inner { SimBuilderInner::Qasm(builder) => { @@ -1237,7 +1243,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -1257,7 +1266,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1431,7 +1443,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -1451,7 +1466,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1608,7 +1626,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -1628,7 +1649,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1697,6 +1721,227 @@ impl PySimBuilder { } } +/// Run a QASM program through the unified `pecos::sim()` facade. +/// +/// Both stacks flow through this one entry: when no stack was selected the +/// facade default governs, so a future default flip in crates/pecos carries +/// the Python surface automatically. Noise mapping for the neo stack stays +/// centralized in the facade (`map_noise_to_neo`); nothing is translated +/// here. +fn run_qasm_via_facade( + builder: &PyQasmSimBuilder, + shots: usize, +) -> PyResult { + let engine_builder = builder + .engine_builder + .lock() + .expect("lock poisoned") + .take() + .ok_or_else(|| PyRuntimeError::new_err("Builder already consumed"))?; + + // Apply a foreign object (WASM) if present, as the direct path did. + let engine_builder = if let Some(ref fo_py) = builder.foreign_object { + Python::attach(|py| -> PyResult<_> { + let fo_bound = fo_py.bind(py); + let wasm_obj: PyRef<'_, PyWasmForeignObject> = + fo_bound.cast::()?.borrow(); + let wasm_bytes = wasm_obj.inner.wasm_bytes().to_vec(); + let qasm_wasm = QasmEngineWasm::from_bytes(wasm_bytes); + Ok(engine_builder.wasm(qasm_wasm)) + })? + } else { + engine_builder + }; + + // A builder with no resolvable QASM program is invalid regardless of + // stack or classical/WASM configuration. Resolve the program first so + // this fundamental error is reported ahead of the neo-specific + // rejections below — otherwise a sourceless `.classical()` + neo would + // misreport the missing source as an unrouted classical override. + let program = engine_builder.get_program().ok_or_else(|| { + PyRuntimeError::new_err("No QASM source specified. Use .qasm() or .qasm_file()") + })?; + + if builder.stack == Some(PySimStack::Neo) && engine_builder.has_wasm() { + return Err(PyRuntimeError::new_err( + "WASM foreign objects are not routed to the neo stack; \ + remove .wasm()/.foreign_object() or use the engines stack", + )); + } + if builder.stack == Some(PySimStack::Neo) && builder.classical_override { + // The facade contract has no classical-engine override on the neo + // stack (the Rust sim().stack(Neo) path rejects it the same way). + // Refuse rather than silently dropping the explicit engine and + // running with only its program. + return Err(PyRuntimeError::new_err( + "Explicit .classical() engine builders are not routed to the neo stack; \ + remove .classical() or use the engines stack", + )); + } + + // The Python QasmEngineBuilder can only carry a program and a WASM + // module. A plain program re-enters through the facade's auto + // selection (identical construction); a WASM-configured engine is + // kept verbatim via the classical override, where the facade never + // reads the program field. + let mut facade = if engine_builder.has_wasm() { + pecos::sim(program).classical(engine_builder) + } else { + pecos::sim(program) + }; + + match builder.stack { + None => {} // the facade default stack governs + Some(PySimStack::Engines) => facade = facade.stack(pecos::SimStack::Engines), + Some(PySimStack::Neo) => facade = facade.stack(pecos::SimStack::Neo), + } + if let Some(seed) = builder.seed { + facade = facade.seed(seed); + } + if let Some(workers) = builder.workers { + facade = facade.workers(workers); + } + if let Some(n) = builder.explicit_num_qubits { + facade = facade.qubits(n); + } + if let Some(ref qe_py) = builder.quantum_engine_builder { + facade = apply_quantum_to_facade(facade, qe_py)?; + } + if let Some(ref noise_py) = builder.noise_builder { + facade = apply_noise_to_facade(facade, noise_py)?; + } + match facade.shots(shots).run() { + Ok(shot_vec) => Ok(crate::shot_results_bindings::PyShotVec::new(shot_vec)), + Err(e) => Err(PyRuntimeError::new_err(format!("Simulation failed: {e}"))), + } +} + +/// Extract a Python quantum-engine builder and apply it to the facade. +fn apply_quantum_to_facade( + facade: pecos::ProgrammedSimBuilder, + qe_py: &Py, +) -> PyResult { + use crate::engine_builders::{ + PyCoinTossEngineBuilder, PyDensityMatrixEngineBuilder, PySparseStabEngineBuilder, + PyStabVecEngineBuilder, PyStabilizerEngineBuilder, PyStateVectorEngineBuilder, + }; + + let consumed = || PyRuntimeError::new_err("Quantum engine builder has already been consumed"); + Python::attach(|py| -> PyResult<_> { + if let Ok(mut state_vec) = qe_py.extract::(py) { + Ok(facade.quantum(state_vec.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut sparse_stab) = qe_py.extract::(py) { + Ok(facade.quantum(sparse_stab.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut stab_vec) = qe_py.extract::(py) { + Ok(facade.quantum(stab_vec.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut density_mat) = qe_py.extract::(py) { + Ok(facade.quantum(density_mat.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut stab) = qe_py.extract::(py) { + Ok(facade.quantum(stab.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut ct) = qe_py.extract::(py) { + Ok(facade.quantum(ct.inner.take().ok_or_else(consumed)?)) + } else { + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) + } + }) +} + +/// Extract a Python noise builder and apply it to the facade. +fn apply_noise_to_facade( + facade: pecos::ProgrammedSimBuilder, + noise_py: &Py, +) -> PyResult { + use crate::engine_builders::{ + PyBiasedDepolarizingNoiseModelBuilder, PyDepolarizingNoiseModelBuilder, + PyGeneralNoiseModelBuilder, + }; + + Python::attach(|py| -> PyResult<_> { + if let Ok(general) = noise_py.extract::(py) { + Ok(facade.noise(general.inner.clone())) + } else if let Ok(depolarizing) = noise_py.extract::(py) { + Ok(facade.noise(depolarizing.inner.clone())) + } else if let Ok(biased) = noise_py.extract::(py) { + Ok(facade.noise(biased.inner.clone())) + } else { + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )) + } + }) +} + +/// Route a HUGR program through the unified `pecos::sim()` facade onto the +/// neo stack (direct HUGR interpretation, no LLVM). +fn run_hugr_neo( + builder: &crate::engine_builders::PyHugrSimBuilder, + shots: usize, +) -> PyResult { + if builder.foreign_object.is_some() { + return Err(PyRuntimeError::new_err( + "WASM foreign objects are not routed to the neo stack; \ + remove .foreign_object() or use the engines stack", + )); + } + let bytes = builder.hugr_bytes.clone().ok_or_else(|| { + PyRuntimeError::new_err("HUGR program bytes are not available for the neo stack route") + })?; + let program = pecos_programs::Hugr::from_bytes(bytes); + run_program_neo( + pecos::sim(program), + builder.seed, + builder.workers, + builder.explicit_num_qubits, + builder.quantum_engine_builder.as_ref(), + builder.noise_builder.as_ref(), + shots, + ) +} + +/// Apply the shared configuration to a facade builder pointed at the neo +/// stack and run it. +fn run_program_neo( + facade: pecos::ProgrammedSimBuilder, + seed: Option, + workers: Option, + qubits: Option, + quantum: Option<&Py>, + noise: Option<&Py>, + shots: usize, +) -> PyResult { + if quantum.is_some() { + return Err(PyRuntimeError::new_err( + "Explicit quantum backends are not yet routed to the neo stack (it uses the \ + default sparse stabilizer); remove .quantum() or use the engines stack", + )); + } + + let mut facade = facade.stack(pecos::SimStack::Neo); + if let Some(seed) = seed { + facade = facade.seed(seed); + } + if let Some(workers) = workers { + facade = facade.workers(workers); + } + if let Some(n) = qubits { + facade = facade.qubits(n); + } + if let Some(noise_py) = noise { + // Shared with the QASM route: extracts the known noise builder + // types and refuses unrecognized objects with a typed error + // listing the accepted constructors. + facade = apply_noise_to_facade(facade, noise_py)?; + } + match facade.shots(shots).run() { + Ok(shot_vec) => Ok(crate::shot_results_bindings::PyShotVec::new(shot_vec)), + Err(e) => Err(PyRuntimeError::new_err(format!("Simulation failed: {e}"))), + } +} + // Clone implementations for the inner types impl Clone for SimBuilderInner { fn clone(&self) -> Self { @@ -1705,6 +1950,7 @@ impl Clone for SimBuilderInner { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() @@ -1712,12 +1958,15 @@ impl Clone for SimBuilderInner { noise_builder: builder.noise_builder.as_ref().map(|obj| obj.clone_ref(py)), explicit_num_qubits: builder.explicit_num_qubits, foreign_object: builder.foreign_object.as_ref().map(|obj| obj.clone_ref(py)), + stack: builder.stack, + classical_override: builder.classical_override, }), SimBuilderInner::QisControl(builder) => { SimBuilderInner::QisControl(PyQisControlSimBuilder { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() @@ -1733,6 +1982,7 @@ impl Clone for SimBuilderInner { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() @@ -1744,6 +1994,7 @@ impl Clone for SimBuilderInner { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() @@ -1753,11 +2004,13 @@ impl Clone for SimBuilderInner { foreign_object: builder.foreign_object.as_ref().map(|obj| obj.clone_ref(py)), keep_intermediate_files: builder.keep_intermediate_files, hugr_bytes: builder.hugr_bytes.clone(), + stack: builder.stack, }), SimBuilderInner::Phir(builder) => SimBuilderInner::Phir(PyPhirSimBuilder { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() diff --git a/python/pecos-rslib/tests/test_llvm_comprehensive.py b/python/pecos-rslib/tests/test_llvm_comprehensive.py index ea4b5de94..7d96c280b 100644 --- a/python/pecos-rslib/tests/test_llvm_comprehensive.py +++ b/python/pecos-rslib/tests/test_llvm_comprehensive.py @@ -37,6 +37,20 @@ def test_pointer_types(qir_module) -> None: _ = qubit_ptr, result_ptr +def test_inttoptr_accepts_typed_pointer_targets(qir_module) -> None: + from pecos_rslib_llvm import ir + + module, ctx = qir_module + + i64 = ctx.int_type(64) + qubit_ty = module.context.get_identified_type("Qubit") + qubit_ptr = qubit_ty.as_pointer() + + ptr = ir.Constant(i64, 0).inttoptr(qubit_ptr) + + assert ptr.type == qubit_ptr + + def test_array_types(qir_module) -> None: _, ctx = qir_module @@ -68,6 +82,46 @@ def test_function_creation(qir_module) -> None: _ = main_func, h_gate, mz_func +def test_load_pointer_function_argument(qir_module) -> None: + from pecos_rslib_llvm import ir + + module, ctx = qir_module + + i32 = ctx.int_type(32) + void = ctx.void_type() + func_type = ctx.function_type(void, [i32.as_pointer()], False) + test_func = module.add_function("load_arg", func_type) + entry = test_func.append_basic_block("entry") + builder = ir.IRBuilder(entry) + + loaded = builder.load(test_func.args[0], "loaded") + builder.ret_void() + + _ = loaded + + +def test_load_pointer_return_value(qir_module) -> None: + from pecos_rslib_llvm import ir + + module, ctx = qir_module + + i32 = ctx.int_type(32) + callee_type = ctx.function_type(i32.as_pointer(), [], False) + callee = module.add_function("returns_i32_ptr", callee_type) + + void = ctx.void_type() + caller_type = ctx.function_type(void, [], False) + caller = module.add_function("load_call_result", caller_type) + entry = caller.append_basic_block("entry") + builder = ir.IRBuilder(entry) + + ptr = builder.call(callee, [], "ptr") + loaded = builder.load(ptr, "loaded") + builder.ret_void() + + _ = loaded + + def test_global_variables(qir_module) -> None: from pecos_rslib_llvm import ir diff --git a/python/quantum-pecos/README.md b/python/quantum-pecos/README.md index c35a5f712..d0c8e7b2a 100644 --- a/python/quantum-pecos/README.md +++ b/python/quantum-pecos/README.md @@ -22,7 +22,7 @@ calls to Wasm VMs, conditional branching, and more. - Fast Simulation: Leverages a fast stabilizer simulation algorithm. - Multi-language extensions: Core functionalities implemented via Rust for performance and safety. Additional add-ons and extension support in C/C++ via Cython. -- LLVM IR Support: Execute LLVM Intermediate Representation programs for hybrid quantum/classical computing. LLVM support is optional - PECOS can be built without LLVM by using `--no-default-features` when building the Rust crates. When LLVM is enabled (default), requires LLVM version 14. +- LLVM IR Support: Execute LLVM Intermediate Representation programs for hybrid quantum/classical computing. LLVM support is optional - PECOS can be built without LLVM by using `--no-default-features` when building the Rust crates. When LLVM is enabled (default), requires LLVM version 21.1. ## Getting Started @@ -116,7 +116,7 @@ pecos = "0.x.x" # Replace with the latest version #### Optional Dependencies -- **LLVM version 14**: Required for LLVM IR execution support (optional) +- **LLVM version 21.1**: Required for LLVM IR execution support (optional) PECOS provides an automated installer or you can install manually: diff --git a/python/quantum-pecos/pyproject.toml b/python/quantum-pecos/pyproject.toml index 960cae2fe..8f8dc7bb0 100644 --- a/python/quantum-pecos/pyproject.toml +++ b/python/quantum-pecos/pyproject.toml @@ -32,9 +32,18 @@ dependencies = [ "pecos-rslib-llvm==0.8.0.dev8", "phir>=0.3.3", "networkx>=2.1.0", - "guppylang>=0.21.6", - "tket<0.12.16", # 0.12.16 requires GLIBC 2.38 (manylinux_2_38) - "hugr>=0.13.0", + # Pin to the 0.21.x line (tket.bool era). guppylang 0.22+ emits the + # tket.measurement HUGR format, which our Rust tket (0.19) cannot read; do not + # uncap until the Rust tket stack is bumped to 0.20+. + "guppylang>=0.21.16,<0.22", + # tket 0.13.x pairs with the latest stable guppylang (0.21.16, tket.bool era). + # Do not bump past 0.13 until a stable guppylang emits tket.measurement (which + # needs Rust tket 0.20+). The old `tket<0.12.16` GLIBC-2.38 cap was stale. + "tket>=0.13,<0.14", + # hugr-py 0.16.x pairs with guppylang 0.21.16 / tket 0.13.x. The runtime relies on + # the binary Model envelope (`Package.to_bytes`/`from_bytes`); cap to the tested + # 0.16 line rather than accept an unverified newer serialization. + "hugr>=0.16,<0.17", "selene-sim~=0.2.0", ] classifiers = [ diff --git a/python/quantum-pecos/src/pecos/_engine_builders.py b/python/quantum-pecos/src/pecos/_engine_builders.py index f8f1e3047..c73f7f919 100644 --- a/python/quantum-pecos/src/pecos/_engine_builders.py +++ b/python/quantum-pecos/src/pecos/_engine_builders.py @@ -17,6 +17,8 @@ from __future__ import annotations +from os import PathLike, fspath +from pathlib import Path from typing import TYPE_CHECKING import pecos_rslib @@ -155,23 +157,21 @@ def program(self, program: Qis | Hugr | CompiledQis) -> Self: self._builder = self._builder.program(program) return self - def selene_runtime(self, runtime_name: str | None = None) -> Self: - """Use Selene simple runtime. + def selene_runtime(self, runtime: object | None = None) -> Self: + """Use a Selene runtime. Args: - runtime_name: Optional runtime name. ``None`` and - ``"selene_simple_runtime"`` both select the default runtime. + runtime: Optional runtime selector. ``None`` selects the default + ``selene_simple_runtime``. A string without path separators is + treated as a built Selene runtime library name. A path-like + value, or any Selene runtime plugin object exposing + ``library_file``, ``get_init_args()``, and optional + ``library_search_dirs``, is passed through generically. Returns: Self for method chaining. """ - if runtime_name not in (None, "selene_simple_runtime"): - msg = ( - "Python QisEngineBuilder.selene_runtime(runtime_name=...) only " - "supports the default 'selene_simple_runtime' wrapper today." - ) - raise NotImplementedError(msg) - self._builder = self._builder.selene_runtime() + self._builder = _configure_selene_runtime(self._builder, runtime) return self def interface(self, builder: object) -> Self: @@ -231,23 +231,53 @@ def qis_engine() -> QisEngineBuilder: return QisEngineBuilder() -def selene_engine(runtime_name: str | None = None) -> QisEngineBuilder: +def _looks_like_library_path(value: str) -> bool: + path = Path(value) + return path.exists() or "/" in value or "\\" in value or path.suffix in {".so", ".dylib", ".dll"} + + +def _configure_selene_runtime(builder: object, runtime: object | None) -> object: + if runtime is None: + return builder.selene_runtime() + + if isinstance(runtime, str): + if _looks_like_library_path(runtime): + return builder.selene_runtime_plugin(runtime) + return builder.selene_runtime(runtime) + + if isinstance(runtime, PathLike): + return builder.selene_runtime_plugin(fspath(runtime)) + + library_file = getattr(runtime, "library_file", None) + if library_file is None: + msg = ( + "Selene runtime must be None, a built runtime name, a shared-library path, " + "or a Selene runtime plugin object with a 'library_file' property." + ) + raise TypeError(msg) + + get_init_args = getattr(runtime, "get_init_args", None) + init_args = list(get_init_args()) if callable(get_init_args) else [] + library_search_dirs = [fspath(path) for path in getattr(runtime, "library_search_dirs", [])] + return builder.selene_runtime_plugin( + fspath(library_file), + [str(arg) for arg in init_args], + library_search_dirs, + ) + + +def selene_engine(runtime: object | None = None) -> QisEngineBuilder: """Create a Selene-backed QIS engine builder. Args: - runtime_name: Optional built Selene runtime library name. - When omitted, the default simple Selene runtime is used. + runtime: Optional runtime selector. ``None`` selects the default + ``selene_simple_runtime``. A built runtime name, shared-library + path, or generic Selene runtime plugin object may also be supplied. Returns: QisEngineBuilder: A builder for Selene-backed QIS/HUGR simulations. """ - if runtime_name not in (None, "selene_simple_runtime"): - msg = ( - "Python selene_engine(runtime_name=...) is not currently supported by the wrapper. " - "Use the default runtime or call into pecos_rslib directly for custom runtime names." - ) - raise NotImplementedError(msg) - return QisEngineBuilder().selene_runtime().interface(pecos_rslib.qis_helios_interface()) + return QisEngineBuilder().selene_runtime(runtime).interface(pecos_rslib.qis_helios_interface()) __all__ = [ diff --git a/python/quantum-pecos/src/pecos/compilation_pipeline.py b/python/quantum-pecos/src/pecos/compilation_pipeline.py index e1593576b..1bdb470d0 100644 --- a/python/quantum-pecos/src/pecos/compilation_pipeline.py +++ b/python/quantum-pecos/src/pecos/compilation_pipeline.py @@ -27,8 +27,6 @@ def compile_guppy_to_hugr(guppy_function: Callable) -> bytes: ValueError: If function is not a Guppy function RuntimeError: If compilation fails """ - from guppylang import guppy as guppy_module - # Check if this is a Guppy function is_guppy = ( hasattr(guppy_function, "_guppy_compiled") @@ -41,111 +39,29 @@ def compile_guppy_to_hugr(guppy_function: Callable) -> bytes: msg = "Function must be decorated with @guppy" raise ValueError(msg) + # guppylang's compile()/compile_function() both return a hugr `Package`. + # Parametric functions must use compile_function() (compile() needs entry-point + # arguments); non-parametric functions use compile() for the entry point. try: - # Check if this is a parametric function (has arguments) import inspect sig = inspect.signature( - (guppy_function.__wrapped__ if hasattr(guppy_function, "__wrapped__") else guppy_function), + guppy_function.__wrapped__ if hasattr(guppy_function, "__wrapped__") else guppy_function, ) has_params = len(sig.parameters) > 0 - - if has_params: - # For parametric functions, use compile_function() which allows parameters - if hasattr(guppy_function, "compile_function"): - compiled = guppy_function.compile_function() - else: - # Fall back to regular compile and let it handle the error - compiled = guppy_function.compile() - else: - # For non-parametric functions, use compile() for entrypoint - if hasattr(guppy_function, "compile"): - # New API: function.compile() - compiled = guppy_function.compile() - else: - # Old API: guppy.compile(function) - compiled = guppy_module.compile(guppy_function) - - # Handle the return value - it might be a FuncDefnPointer or similar - # Use the new HUGR envelope methods (to_str/to_bytes) instead of deprecated to_json - if hasattr(compiled, "to_str"): - # Use string format for JSON compatibility with HUGR 0.13 compiler - return compiled.to_str().encode("utf-8") - if hasattr(compiled, "to_json"): - # Fallback to to_json for older versions (with deprecation warning) - return compiled.to_json().encode("utf-8") - - if hasattr(compiled, "package"): - if hasattr(compiled.package, "to_str"): - return compiled.package.to_str().encode("utf-8") - if hasattr(compiled.package, "to_json"): - return compiled.package.to_json().encode("utf-8") - return compiled.package.to_bytes() - - if hasattr(compiled, "to_package"): - package = compiled.to_package() - if hasattr(package, "to_str"): - return package.to_str().encode("utf-8") - if hasattr(package, "to_json"): - return package.to_json().encode("utf-8") - return package.to_bytes() - - # Try to serialize directly - return compiled.to_bytes() + compiled = guppy_function.compile_function() if has_params else guppy_function.compile() except Exception as e: msg = f"Failed to compile Guppy to HUGR: {e}" raise RuntimeError(msg) from e - -# Step 2: HUGR -> LLVM/QIR -def _update_tket_wasm_version(hugr_bytes: bytes) -> bytes: - """Update tket.wasm version from 0.3.0 to 0.4.1 for compatibility. - - Args: - hugr_bytes: HUGR package bytes - - Returns: - Updated HUGR bytes with tket.wasm 0.4.1 - """ - import json - - hugr_str = hugr_bytes.decode("utf-8") - - # Check if it starts with the envelope header - if hugr_str.startswith("HUGRiHJv"): - # Find where the JSON starts - json_start = hugr_str.find("{", 8) - if json_start != -1: - header = hugr_str[:json_start] - json_part = hugr_str[json_start:] - - # Parse the JSON - hugr_data = json.loads(json_part) - - # Update version in extensions - if "extensions" in hugr_data: - for ext in hugr_data["extensions"]: - if ext.get("name") == "tket.wasm" and ext.get("version") == "0.3.0": - ext["version"] = "0.4.1" - - # Update version in module metadata - if hugr_data.get("modules"): - module = hugr_data["modules"][0] - if "metadata" in module: - for meta_item in module["metadata"]: - if isinstance(meta_item, dict) and "core.used_extensions" in meta_item: - for ext in meta_item["core.used_extensions"]: - if ext.get("name") == "tket.wasm" and ext.get("version") == "0.3.0": - ext["version"] = "0.4.1" - - # Reconstruct the HUGR envelope - modified_json = json.dumps(hugr_data, separators=(",", ":")) - modified_hugr = header + modified_json - return modified_hugr.encode("utf-8") - - return hugr_bytes + # Serialize the Package as the BINARY HUGR envelope (Model format). The Selene/QIS + # engine's HUGR reader rejects hugr-py 0.16's S-expression *text* envelope + # (`to_str`) with "Failed to read HUGR", whereas the binary Model form round-trips + # cleanly, including CFG loops (while statements). + return compiled.to_bytes() +# Step 2: HUGR -> LLVM/QIR def compile_hugr_to_qis( hugr_bytes: bytes, *, diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index d2344d55b..56119208e 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -23,6 +23,7 @@ BpOsdDecoder, BpResult, CheckMatrix, + DemAwareDecoder, FusionBlossomDecoder, MinSumBpBuilder, MinSumBpDecoder, @@ -48,6 +49,7 @@ "BpOsdDecoder", "BpResult", "CheckMatrix", + "DemAwareDecoder", "DummyDecoder", "FusionBlossomDecoder", "MinSumBpBuilder", diff --git a/python/quantum-pecos/src/pecos/guppy/__init__.py b/python/quantum-pecos/src/pecos/guppy/__init__.py index 6b0400df9..b701bc214 100644 --- a/python/quantum-pecos/src/pecos/guppy/__init__.py +++ b/python/quantum-pecos/src/pecos/guppy/__init__.py @@ -46,6 +46,7 @@ make_surface_transversal_cnot, make_surface_transversal_cnot_with_x, ) +from pecos.guppy.variant import variant_scoped __all__ = [ # Surface code @@ -72,4 +73,6 @@ "make_color_transversal_cnot_with_x_d3", "make_surface_transversal_cnot", "make_surface_transversal_cnot_with_x", + # Variant-scoped program factories + "variant_scoped", ] diff --git a/python/quantum-pecos/src/pecos/guppy/color.py b/python/quantum-pecos/src/pecos/guppy/color.py index 7e2e198e9..9be15bf9c 100644 --- a/python/quantum-pecos/src/pecos/guppy/color.py +++ b/python/quantum-pecos/src/pecos/guppy/color.py @@ -10,6 +10,7 @@ import importlib.util import sys import tempfile +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -22,7 +23,7 @@ class _ModuleState: """Container for module-level mutable state.""" temp_dir: ClassVar[Path | None] = None - module_cache: ClassVar[dict[int, dict]] = {} + module_cache: ClassVar[dict[tuple[int, int | None], dict]] = {} _state = _ModuleState() @@ -339,17 +340,21 @@ def generate_color_code_source(code: "ColorCode488") -> str: return "\n".join(lines) -def _load_color_code_module(d: int) -> dict: +def _load_color_code_module(d: int, *, num_rounds: int | None = None) -> dict: """Load a color code module for distance d, using caching. Args: d: Code distance + num_rounds: Optional memory-round count. When supplied, the generated + module name is round-scoped so Guppy's module-qualified function + names cannot reuse a stale factory-local comptime body. Returns: Module dictionary with generated functions """ - if d in _state.module_cache: - return _state.module_cache[d] + cache_key = (d, None if num_rounds is None else int(num_rounds)) + if cache_key in _state.module_cache: + return _state.module_cache[cache_key] from pecos.qec.color import ColorCode488 @@ -358,11 +363,12 @@ def _load_color_code_module(d: int) -> dict: # Write to temp file temp_dir = _get_temp_dir() - temp_file = temp_dir / f"color_d{d}.py" + round_suffix = "" if num_rounds is None else f"_r{int(num_rounds)}" + temp_file = temp_dir / f"color_d{d}{round_suffix}.py" temp_file.write_text(source) # Load module - module_name = f"pecos._generated.color_d{d}" + module_name = f"pecos._generated.color_d{d}{round_suffix}" spec = importlib.util.spec_from_file_location(module_name, temp_file) if spec is None or spec.loader is None: msg = f"Failed to create module spec for {temp_file}" @@ -372,13 +378,36 @@ def _load_color_code_module(d: int) -> dict: sys.modules[module_name] = module spec.loader.exec_module(module) - _state.module_cache[d] = vars(module) - return _state.module_cache[d] + _state.module_cache[cache_key] = vars(module) + return _state.module_cache[cache_key] + + +def _round_scoped_color_memory_factory(d: int, basis: str) -> Callable[[int], object]: + """Memory factory that scopes each call to a round-specific Guppy module. + + Backs :func:`get_color_code_module`: each ``factory(n)`` re-enters + :func:`_load_color_code_module` with the concrete ``num_rounds`` so it lands + in a distinct ``pecos._generated.color_d{d}_r{n}`` module, preventing a + cross-round guppy module-qualified-name collision. + """ + + def factory(num_rounds: int) -> object: + scoped = _load_color_code_module(d, num_rounds=int(num_rounds)) + return scoped[f"make_memory_{basis}"](num_rounds) + + return factory def get_color_code_module(d: int) -> dict: """Get a loaded color code module for distance d. + The returned ``make_memory_z``/``make_memory_x`` factories scope each call to + a round-specific module: ``factory(n)`` always produces the experiment in a + distinct ``pecos._generated.color_d{d}_r{n}`` module. The round count comes + solely from the factory argument, so building experiments at several round + counts in one process stays isolated (guppylang caches compiled functions by + module-qualified name). + Args: d: Code distance (must be odd >= 3) @@ -396,6 +425,16 @@ def get_color_code_module(d: int) -> dict: module["num_data"] = code.num_data module["num_stab"] = code.num_stabilizers + # Scope each factory call to a round-specific module (see + # _round_scoped_color_memory_factory). Copy first so the cached + # _load_color_code_module namespace keeps its real factories for the + # round-scoped re-entry. + module = dict(module) + for basis in ("z", "x"): + key = f"make_memory_{basis}" + if key in module: + module[key] = _round_scoped_color_memory_factory(d, basis) + return module @@ -429,7 +468,7 @@ def make_color_code(distance: int, num_rounds: int, basis: str) -> object: msg = f"basis must be 'Z' or 'X', got {basis!r}" raise ValueError(msg) - module = get_color_code_module(distance) + module = _load_color_code_module(distance, num_rounds=num_rounds) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 3307f294c..fd2d5bcb7 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -12,15 +12,18 @@ """ import importlib.util +import json import sys import tempfile +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, ClassVar from pecos.qec.surface.schedule import compute_cnot_schedule if TYPE_CHECKING: - from pecos.qec.surface import SurfacePatch + from pecos.qec.surface import GuppyRngMaskConfig, SurfacePatch, TwirlConfig + from pecos.qec.surface._check_plan import ResolvedSurfaceCheckPlan # Module state container (avoids global statement) @@ -32,12 +35,62 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, str], dict]] = {} _state = _ModuleState() +_SZZ_RUNTIME_BARRIER_POLICY_NONE = "none" +_SZZ_RUNTIME_BARRIER_POLICY_ALL = "all" +_SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX = "data-prefix" +_SZZ_RUNTIME_BARRIER_POLICIES = frozenset( + { + _SZZ_RUNTIME_BARRIER_POLICY_NONE, + _SZZ_RUNTIME_BARRIER_POLICY_ALL, + _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX, + }, +) +_PACKED_TRACE_METADATA_JSON_KEY = "__pecos_trace_metadata_json_v1__" + + +def _normalize_szz_runtime_barrier_policy(value: bool | str) -> str: + """Return the canonical SZZ runtime-barrier policy token.""" + if isinstance(value, bool): + return _SZZ_RUNTIME_BARRIER_POLICY_ALL if value else _SZZ_RUNTIME_BARRIER_POLICY_NONE + normalized = str(value).strip().lower().replace("_", "-") + if normalized in {"1", "true", "t", "yes", "y", "on"}: + return _SZZ_RUNTIME_BARRIER_POLICY_ALL + if normalized in {"0", "false", "f", "no", "n", "off"}: + return _SZZ_RUNTIME_BARRIER_POLICY_NONE + if normalized not in _SZZ_RUNTIME_BARRIER_POLICIES: + msg = ( + "szz_runtime_barriers must be a boolean or one of " + f"{sorted(_SZZ_RUNTIME_BARRIER_POLICIES)}, got {value!r}" + ) + raise ValueError(msg) + return normalized + + +def _normalize_surface_interaction_basis(interaction_basis: str) -> str: + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + return _normalize_interaction_basis(interaction_basis) + + +def _resolve_surface_check_plan( + *, + interaction_basis: str | None = None, + check_plan: str | None = None, +) -> "ResolvedSurfaceCheckPlan": + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + return resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + + def _get_temp_dir() -> Path: """Get or create temporary directory for generated code.""" if _state.temp_dir is None: @@ -45,14 +98,93 @@ def _get_temp_dir() -> Path: return _state.temp_dir +def _render_inline_pcg32() -> list[str]: + """Render Guppy-local PCG32 helpers for runtime twirl masks. + + The user seed is a stream separator. Per-shot entropy comes from + H/measure side-band qubits so the runtime twirl is not a fixed mask. + """ + return [ + "@guppy", + "@no_type_check", + "def _pcg32_mask32(value: nat) -> nat:", + " uint32_mask: nat = 4294967295", + " return value & uint32_mask", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_advance(state: nat, inc: nat) -> nat:", + " pcg32_mult: nat = 6364136223846793005", + " return nat(state * pcg32_mult + inc)", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_next32(state: nat, inc: nat) -> tuple[nat, nat]:", + " old_state = state", + " new_state = _pcg32_advance(state, inc)", + " xorshifted = _pcg32_mask32(((old_state >> nat(18)) ^ old_state) >> nat(27))", + " rot = _pcg32_mask32(old_state >> nat(59))", + " rot_inv = _pcg32_mask32((~rot + nat(1)) & nat(31))", + " output = _pcg32_mask32((xorshifted >> rot) | (xorshifted << rot_inv))", + " return new_state, output", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:", + " new_state, output = _pcg32_next32(state, inc)", + " return new_state, int(output & nat(3))", + "", + "", + "@guppy", + "@no_type_check", + "def seeded_pcg32_from_sequence(seed: int, sequence: nat) -> tuple[nat, nat]:", + " initstate = nat(42)", + " initseq = nat(seed) ^ sequence", + " inc = nat((initseq << nat(1)) | nat(1))", + " state = _pcg32_advance(nat(0), inc)", + " state += initstate", + " state = _pcg32_advance(state, inc)", + " return state, inc", + "", + "", + "@guppy", + "@no_type_check", + "def seeded_pcg32_with_quantum_entropy(seed: int) -> tuple[nat, nat]:", + " entropy = nat(0)", + " for i in range(32):", + " entropy_q = qubit()", + " h(entropy_q)", + " if measure(entropy_q):", + " entropy = entropy | (nat(1) << nat(i))", + " return seeded_pcg32_from_sequence(seed, entropy)", + "", + "", + ] + + def generate_guppy_source( patch: "SurfacePatch", *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> str: """Generate Guppy source code for a surface code patch. - Uses a 4-round parallel CNOT schedule for syndrome extraction. + Uses a 4-round parallel schedule for syndrome extraction. The default + ``interaction_basis="cx"`` emits the CNOT template; ``"szz"`` emits the + signed SZZ/SZZdg template. SZZ helpers forward-flow single-qubit data + frames within each helper and then explicitly flush the frame before + returning, preserving the reusable Guppy result-tag structure. ``ancilla_budget=None`` (default) emits the unconstrained shape: one ancilla per stabilizer, all measured in parallel at the end of @@ -67,7 +199,8 @@ def generate_guppy_source( 4-round CX schedule restricted to that batch's stabilizers, measure, then move to the next batch (which allocates fresh qubits whose physical slots are reused by Selene's lowering). - The same per-stabilizer ``result("...:meas:N", …)`` calls fire + Per-stabilizer counted-round ``result("...:meas:N", …)`` calls + and prep-boundary ``result("...:init:meas:N", …)`` calls fire in the abstract's batched measurement order, keeping detector record offsets transferable between abstract and traced paths. @@ -77,11 +210,96 @@ def generate_guppy_source( ancilla_budget: Optional cap on simultaneously live ancillas. ``None`` or a value ``>= total_ancilla`` emits the unconstrained shape; ``< total_ancilla`` emits batched. + twirl: When provided, emit Pauli-twirl-site mask draws between + consecutive syndrome rounds and apply the sampled physical + Pauli to each data qubit at runtime. Both ``twirl`` and + ``rng`` must be supplied together. The encoding is + ``"bool_array_v1"``: one + ``result("pauli_mask:round:R", array(lo_q0, hi_q0, ...))`` + call per twirl site, with the per-round body Python-time + unrolled at source-generation time so each tag fires exactly + once per shot. ``twirl.frame_output="canonical"`` additionally + emits measurement records in the canonical untwirled DEM frame. + rng: Runtime mask source: a stream-separator seed mixed with + per-shot quantum entropy when ``twirl`` is enabled. + num_rounds: Number of syndrome rounds to render. Required when + ``twirl`` is enabled because twirled source is unrolled per round. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Current Guppy generation maps the resolved plan to the + corresponding CX or SZZ/SZZdg concrete template. + clifford_frame_policy: Optional source-level Clifford-deformation + policy. Currently supported for SZZ global axis-cycle and + checkerboard XZZX/ZXXZ deformed-check frames. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. ``False`` + or ``"none"`` emits no barriers; ``True`` or ``"all"`` emits a + public Guppy ``barrier`` before every SZZ/SZZdg host region; + ``"data-prefix"`` emits one only after a non-virtual local + data-frame pulse is discharged and before its host. Barriers have + no ideal-unitary effect, but give runtimes a principled scheduling + boundary between selected local data-frame pulses and their + entangling host. + trace_metadata: Emit PECOS trace metadata helpers for SZZ/SZZdg + hosted-operation diagnostics and strict DEM construction. Disable + only for execution-only builds whose compiler/linker cannot resolve + PECOS trace metadata helper symbols. Returns: Python/Guppy source code as a string. + + Raises: + ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget + from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + ) + from pecos.qec.surface.circuit_builder import ( + _SZZ_FLOW_IDENTITY, + _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING, + OpType, + _resolve_szz_clifford_frame_for_builder, + _szz_flow_clifford_name, + _szz_flow_compose_pending_gate, + _szz_flow_is_virtual_z, + _szz_memory_physical_axis_for_data, + _szz_residual_plan_for_check_plan, + ) + + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="Guppy surface-code source generation", + ) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) + if interaction_basis != "szz" and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE: + msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" + raise ValueError(msg) + resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) + if (twirl is None) != (rng is None): + msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" + raise ValueError(msg) + if twirl is not None: + twirl.validate_runtime_supported() + if num_rounds is None: + msg = "num_rounds is required when twirl is supplied" + raise ValueError(msg) + if num_rounds < 1: + msg = f"num_rounds must be >= 1, got {num_rounds}" + raise ValueError(msg) + canonical_frame_output = twirl is not None and twirl.frame_output == "canonical" geom = patch.geometry num_data = geom.num_data @@ -90,8 +308,50 @@ def generate_guppy_source( total_ancilla = num_x_stab + num_z_stab effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) constrained = effective_budget < total_ancilla + if interaction_basis == "szz" and twirl is not None: + msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" + raise ValueError(msg) + if twirl is not None and constrained: + msg = ( + f"twirl + constrained ancilla budget is not supported on " + f"the Guppy runtime path " + f"(ancilla_budget={ancilla_budget} < total_ancilla={total_ancilla}); " + "the runtime twirl-site schedules assume the " + "unconstrained syndrome shape. Pass ancilla_budget=None or " + ">= total_ancilla, or omit twirl." + ) + raise ValueError(msg) dx, dz = geom.dx, geom.dz + if twirl is not None: + imports = [ + "from __future__ import annotations", + "from typing import no_type_check", + "", + "from guppylang import guppy", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.num import nat", + "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x, y, z", + ] + elif interaction_basis == "szz": + imports = [ + "from __future__ import annotations", + "", + "from guppylang import guppy", + "from guppylang.std.angles import angle", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.qsystem.functional import phased_x, rz, zz_phase", + "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", + ] + else: + imports = [ + "from __future__ import annotations", + "", + "from guppylang import guppy", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x, z", + ] + lines = [ f'"""Surface code patch (dx={dx}, dz={dz}) implementation in Guppy.', "", @@ -101,23 +361,64 @@ def generate_guppy_source( f"X stabilizers: {num_x_stab}", f"Z stabilizers: {num_z_stab}", f"Ancilla qubits: {num_x_stab + num_z_stab} (one per stabilizer)", + f"Interaction basis: {interaction_basis}", + f"Check plan: {resolved_plan.plan_id}", '"""', "", - "from guppylang import guppy", - "from guppylang.std.builtins import array, owned, result", - "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x", + *imports, "", "", ] - # Generate struct definitions + if twirl is not None: + lines.extend(_render_inline_pcg32()) + + if interaction_basis == "szz": + helper_declarations: list[str] = [] + if trace_metadata: + helper_declarations.extend( + [ + "@guppy.declare", + ( + "def pecos_qis_trace_metadata_qubit_hugr(" + "q: qubit @ owned, key: str, value: str" + ") -> qubit: ..." + ), + "", + ], + ) + helper_declarations.extend( + [ + "@guppy.declare", + "def pecos_qis_runtime_barrier_qubit_hugr(q: qubit @ owned) -> qubit: ...", + "", + "@guppy.declare", + ( + "def pecos_qis_runtime_barrier_qubits2_hugr(" + "q0: qubit @ owned, q1: qubit @ owned" + ") -> tuple[qubit, qubit]: ..." + ), + "", + "", + ], + ) + lines.extend(helper_declarations) + + # Generate struct definitions. lines.extend( [ "@guppy.struct", f"class SurfaceCode_{dx}x{dz}:", f' """Surface code patch with dx={dx}, dz={dz} ({num_data} data qubits)."""', "", - f" data: array[qubit, {num_data}]", + ], + ) + if interaction_basis == "szz": + lines.extend(f" d{i}: qubit" for i in range(num_data)) + else: + lines.append(f" data: array[qubit, {num_data}]") + lines.extend( + [ "", "", "@guppy.struct", @@ -131,41 +432,487 @@ def generate_guppy_source( ], ) - # Generate state preparation functions - lines.extend( - [ - "# === State Preparation ===", - "", - "@guppy", - f"def prep_z_basis() -> SurfaceCode_{dx}x{dz}:", - ' """Prepare logical |0_L> state."""', - f" data = array(qubit() for _ in range({num_data}))", - f" return SurfaceCode_{dx}x{dz}(data)", - "", - "", - "@guppy", - f"def prep_x_basis() -> SurfaceCode_{dx}x{dz}:", - ' """Prepare logical |+_L> state."""', - f" data = array(qubit() for _ in range({num_data}))", - f" for i in range({num_data}):", - " h(data[i])", - f" return SurfaceCode_{dx}x{dz}(data)", - "", - "", - ], + szz_data_args = ", ".join(f"d{i}" for i in range(num_data)) + + def _append_szz_data_unpack(target: list[str], indent: str) -> None: + target.extend(f"{indent}d{i} = surf.d{i}" for i in range(num_data)) + + def _szz_data_expr(data_q: int) -> str: + return f"d{data_q}" + + szz_check_by_key = {} + if resolved_clifford_frame is not None: + szz_check_by_key.update({("X", check.stabilizer_index): check for check in resolved_clifford_frame.x_checks}) + szz_check_by_key.update({("Z", check.stabilizer_index): check for check in resolved_clifford_frame.z_checks}) + + def _szz_physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_q: int) -> str: + if resolved_clifford_frame is None: + return "X" if stabilizer_type == "X" else "Z" + check = szz_check_by_key[(stabilizer_type, stab_idx)] + try: + offset = check.data_qubits.index(data_q) + except ValueError as exc: + msg = f"data qubit {data_q} is not in resolved check {stabilizer_type}{stab_idx}" + raise ValueError(msg) from exc + return check.paulis[offset].axis + + def _szz_physical_axis_for_memory_data(source_basis: str, data_q: int) -> str: + return _szz_memory_physical_axis_for_data( + source_basis, + resolved_clifford_frame, + data_q, + ) + + def _szz_physical_axis_for_logical_data(source_logical: str, data_q: int) -> str: + if resolved_clifford_frame is None: + return source_logical + logical = resolved_clifford_frame.logical_x if source_logical == "X" else resolved_clifford_frame.logical_z + try: + offset = logical.data_qubits.index(data_q) + except ValueError as exc: + msg = f"data qubit {data_q} is not in source logical {source_logical}" + raise ValueError(msg) from exc + return logical.paulis[offset].axis + + def _append_szz_axis_rotation_to_z(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}h({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}vdg({qubit_expr})") + elif axis != "Z": + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_axis_rotation_from_z(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}h({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}v({qubit_expr})") + elif axis != "Z": + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_y_compensation(target: list[str], indent: str, qubit_expr: str, *, dagger: bool) -> None: + target.append(f"{indent}sdg({qubit_expr})") + target.append(f"{indent}{'vdg' if dagger else 'v'}({qubit_expr})") + target.append(f"{indent}s({qubit_expr})") + + def _append_szz_touch_compensation(target: list[str], indent: str, axis: str, sign: int, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}{'vdg' if sign > 0 else 'v'}({qubit_expr})") + elif axis == "Y": + _append_szz_y_compensation(target, indent, qubit_expr, dagger=sign > 0) + elif axis == "Z": + target.append(f"{indent}{'sdg' if sign > 0 else 's'}({qubit_expr})") + else: + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _szz_axis_rotation_to_z_gates(axis: str) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.H,) + if axis == "Y": + return (OpType.SXDG,) + if axis == "Z": + return () + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _szz_axis_rotation_from_z_gates(axis: str) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.H,) + if axis == "Y": + return (OpType.SX,) + if axis == "Z": + return () + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.SXDG if sign > 0 else OpType.SX,) + if axis == "Y": + return ( + OpType.SZDG, + OpType.SXDG if sign > 0 else OpType.SX, + OpType.SZ, + ) + if axis == "Z": + return (OpType.SZDG if sign > 0 else OpType.SZ,) + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_trace_metadata_payload( + target: list[str], + indent: str, + metadata: dict[str, str], + qubit_expr: str, + ) -> None: + if not trace_metadata or not metadata: + return + payload = json.dumps(metadata, separators=(",", ":"), sort_keys=True) + target.append( + f"{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr(" + f"{qubit_expr}, " + f"{json.dumps(_PACKED_TRACE_METADATA_JSON_KEY)}, " + f"{json.dumps(payload)})", + ) + + def _append_szz_gate_trace_metadata( + target: list[str], + indent: str, + *, + source_kind: str, + source_label: str, + qubit_expr: str, + host_label: str | None = None, + local_role: str | None = None, + gate: OpType | None = None, + lowering_required: bool = False, + ) -> None: + metadata = { + "source_kind": source_kind, + "source_label": source_label, + } + if host_label is not None: + metadata["szz_host_label"] = host_label + metadata["host_id"] = host_label + if local_role is not None: + metadata["local_role"] = local_role + if gate is not None: + metadata["source_gate"] = gate.name + if lowering_required: + metadata["source_lowering_required"] = "true" + _append_szz_trace_metadata_payload(target, indent, metadata, qubit_expr) + + def _append_szz_flow_gate( + target: list[str], + indent: str, + op_type: OpType, + qubit_expr: str, + *, + source_label: str | None = None, + host_label: str | None = None, + ) -> None: + op_name = { + OpType.H: "h", + OpType.SX: "v", + OpType.SXDG: "vdg", + OpType.SZ: "s", + OpType.SZDG: "sdg", + OpType.X: "x", + OpType.Z: "z", + }.get(op_type) + if op_name is None: + msg = f"unsupported Guppy SZZ forward-flow gate {op_type.name}" + raise ValueError(msg) + if source_label is not None: + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_data_prefix", + source_label=source_label, + qubit_expr=qubit_expr, + host_label=host_label, + local_role="basis_prefix", + gate=op_type, + ) + target.append(f"{indent}{op_name}({qubit_expr})") + + def _append_szz_physical_prefix_gate( + target: list[str], + indent: str, + op_type: OpType, + qubit_expr: str, + *, + source_label: str, + host_label: str, + ) -> None: + """Append one hosted physical SZZ prefix pulse. + + Guppy's public quantum stdlib does not expose PECOS ``F``/``SY`` + names directly. The hardware-level SZZ forward-flow table still has + a one-pulse interpretation: ``SY`` is a Y-axis sqrt pulse, and ``F`` + is an X-axis sqrt pulse plus a virtual Z-frame update. We attach the + hosted metadata only to the physical pulse so scheduling diagnostics + track the operation that must remain adjacent to its SZZ/SZZdg host. + """ + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_data_prefix", + source_label=source_label, + qubit_expr=qubit_expr, + host_label=host_label, + local_role="basis_prefix", + gate=op_type, + ) + if op_type == OpType.H: + target.append(f"{indent}h({qubit_expr})") + elif op_type == OpType.SX: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.0))") + elif op_type == OpType.SXDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(-0.5), angle(0.0))") + elif op_type == OpType.SY: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.5))") + elif op_type == OpType.SYDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(-0.5), angle(0.5))") + elif op_type == OpType.F: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.0))") + target.append(f"{indent}{qubit_expr} = rz({qubit_expr}, angle(0.5))") + elif op_type == OpType.FDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(-0.5))") + target.append(f"{indent}{qubit_expr} = rz({qubit_expr}, angle(-0.5))") + else: + msg = f"unsupported hosted SZZ physical prefix gate {op_type.name}" + raise ValueError(msg) + + def _szz_guppy_physical_prefix_for_pending( + pending: tuple[int, int], + ) -> tuple[OpType | None, OpType]: + """Return the source-level physical-prefix lowering for ``pending``.""" + try: + return _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[pending] + except KeyError as exc: + msg = f"SZZ Guppy hosted-prefix lowering cannot lower pending Clifford {_szz_flow_clifford_name(pending)}" + raise ValueError(msg) from exc + + _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} + _szz_guppy_prefix_generators = ( + OpType.H, + OpType.SX, + OpType.SXDG, + OpType.SZ, + OpType.SZDG, + OpType.X, + OpType.Z, ) - # Generate syndrome extraction with parallel CNOT schedule. + def _szz_guppy_prefix_gates_for_pending(pending: tuple[int, int]) -> tuple[OpType, ...]: + """Return an exact Guppy-supported 1q Clifford sequence for ``pending``.""" + cached = _szz_guppy_prefix_cache.get(pending) + if cached is not None: + return cached + + queue: list[tuple[tuple[int, int], tuple[OpType, ...]]] = [(_SZZ_FLOW_IDENTITY, ())] + seen = {_SZZ_FLOW_IDENTITY} + while queue: + current, gates = queue.pop(0) + for gate in _szz_guppy_prefix_generators: + next_pending = _szz_flow_compose_pending_gate(current, gate) + if next_pending in seen: + continue + next_gates = (*gates, gate) + _szz_guppy_prefix_cache[next_pending] = next_gates + if next_pending == pending: + return next_gates + seen.add(next_pending) + queue.append((next_pending, next_gates)) + + msg = f"cannot synthesize Guppy SZZ forward-flow Clifford prefix for {pending!r}" + raise ValueError(msg) + + def _append_szz_flush_data_frame( + target: list[str], + indent: str, + pending_by_data: dict[int, tuple[int, int]], + *, + reason: str, + ) -> None: + """Materialize pending data Cliffords before returning a frame-free helper.""" + emitted_comment = False + for data_q, pending in sorted(pending_by_data.items()): + if pending == _SZZ_FLOW_IDENTITY: + continue + prefix = _szz_guppy_prefix_gates_for_pending(pending) + if prefix and not emitted_comment: + target.append("") + target.append(f"{indent}# Flush SZZ data frame before {reason}") + emitted_comment = True + for gate in prefix: + _append_szz_flow_gate(target, indent, gate, f"d{data_q}") + pending_by_data[data_q] = _SZZ_FLOW_IDENTITY + + def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}x({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}y({qubit_expr})") + elif axis == "Z": + target.append(f"{indent}z({qubit_expr})") + else: + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + # Generate state preparation functions + lines.extend(["# === State Preparation ===", "", "@guppy", f"def prep_z_basis() -> SurfaceCode_{dx}x{dz}:"]) + lines.append(' """Prepare logical |0_L> state."""') + if interaction_basis == "szz": + lines.extend(f" d{i} = qubit()" for i in range(num_data)) + for i in range(num_data): + _append_szz_axis_rotation_to_z( + lines, + " ", + _szz_physical_axis_for_memory_data("Z", i), + f"d{i}", + ) + lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") + else: + lines.append(f" data = array(qubit() for _ in range({num_data}))") + lines.append(f" return SurfaceCode_{dx}x{dz}(data)") + lines.extend(["", "", "@guppy", f"def prep_x_basis() -> SurfaceCode_{dx}x{dz}:"]) + lines.append(' """Prepare logical |+_L> state."""') + if interaction_basis == "szz": + lines.extend(f" d{i} = qubit()" for i in range(num_data)) + for i in range(num_data): + _append_szz_axis_rotation_to_z( + lines, + " ", + _szz_physical_axis_for_memory_data("X", i), + f"d{i}", + ) + lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") + else: + lines.append(f" data = array(qubit() for _ in range({num_data}))") + lines.append(f" for i in range({num_data}):") + lines.append(" h(data[i])") + lines.append(f" return SurfaceCode_{dx}x{dz}(data)") + lines.extend(["", ""]) + + # Generate syndrome extraction with the selected parallel interaction schedule. rounds = compute_cnot_schedule(patch) + szz_sign_by_touch: dict[tuple[str, int, int], int] = {} + if interaction_basis == "szz": + residual_plan = _szz_residual_plan_for_check_plan(patch, resolved_plan) + szz_sign_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign + for entry in residual_plan.signs + } + + def _szz_ancilla_expr(stab_type: str, stab_idx: int) -> str: + return f"ax{stab_idx}" if stab_type == "X" else f"az{stab_idx}" + + def _append_szz_layer( + target: list[str], + indent: str, + rnd_idx: int, + layer_gates: list[tuple[str, int, int]], + ancilla_expr: Callable[[str, int], str], + data_expr: Callable[[int], str], + pending_by_data: dict[int, tuple[int, int]] | None = None, + host_label_scope: str | None = None, + ) -> None: + def compose_data(data_q: int, gates: tuple[OpType, ...]) -> None: + if pending_by_data is None: + for gate in gates: + _append_szz_flow_gate(target, indent, gate, data_expr(data_q)) + return + pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + for gate in gates: + pending = _szz_flow_compose_pending_gate(pending, gate) + pending_by_data[data_q] = pending + + def discharge_data_for_szz( + data_q: int, + *, + host_label: str, + ) -> None: + if pending_by_data is None: + return + pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + if pending == _SZZ_FLOW_IDENTITY or _szz_flow_is_virtual_z(pending): + return + virtual_gate, physical_gate = _szz_guppy_physical_prefix_for_pending(pending) + if virtual_gate is not None: + _append_szz_flow_gate(target, indent, virtual_gate, data_expr(data_q)) + _append_szz_physical_prefix_gate( + target, + indent, + physical_gate, + data_expr(data_q), + source_label=f"{host_label}:prefix:0:{physical_gate.name}", + host_label=host_label, + ) + pending_by_data[data_q] = _SZZ_FLOW_IDENTITY + + target.append("") + target.append(f"{indent}# SZZ round {rnd_idx + 1}") + for stab_type, stab_idx, data_q in layer_gates: + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + compose_data(data_q, _szz_axis_rotation_to_z_gates(axis)) + + for stab_type, stab_idx, data_q in layer_gates: + pending = ( + _SZZ_FLOW_IDENTITY + if pending_by_data is None + else pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + ) + has_data_prefix = pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) + sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG + host_label_core = f"r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + host_label = ( + f"szz:{host_label_core}" if host_label_scope is None else f"szz:{host_label_scope}:{host_label_core}" + ) + if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( + szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX and has_data_prefix + ): + target.append( + f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " + "pecos_qis_runtime_barrier_qubits2_hugr(" + f"{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)})", + ) + discharge_data_for_szz(data_q, host_label=host_label) + half_turns = "0.5" if sign > 0 else "-0.5" + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_host", + source_label=host_label, + qubit_expr=data_expr(data_q), + host_label=host_label, + gate=host_gate, + lowering_required=True, + ) + target.append( + f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " + f"zz_phase({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)}, angle({half_turns}))", + ) + + for stab_type, stab_idx, data_q in layer_gates: + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + compose_data(data_q, _szz_axis_rotation_from_z_gates(axis)) + + for stab_type, stab_idx, data_q in layer_gates: + sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + compose_data(data_q, _szz_touch_compensation_gates(axis, sign)) lines.extend( [ "# === Syndrome Extraction ===", "", "@guppy", - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", ], ) + if canonical_frame_output: + lines.append( + "def syndrome_extraction(" + f"surf: SurfaceCode_{dx}x{dz}, " + f"frame_x: array[bool, {num_data}], " + f"frame_z: array[bool, {num_data}]" + f") -> Syndrome_{dx}x{dz}:", + ) + else: + if interaction_basis == "szz": + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, Syndrome_{dx}x{dz}]:", + ) + else: + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", + ) if not constrained: # Unconstrained: one ancilla per stabilizer, X-stabs first then @@ -173,50 +920,112 @@ def generate_guppy_source( # abstract circuit's unconstrained-path measurement order. lines.extend( [ - ' """Extract full syndrome using 4-round parallel CNOT schedule."""', - " # Allocate ancilla qubits (one per stabilizer)", + ( + ' """Extract full syndrome using 4-round parallel SZZ/SZZdg schedule."""' + if interaction_basis == "szz" + else ' """Extract full syndrome using 4-round parallel CNOT schedule."""' + ), ], ) + if interaction_basis == "szz": + lines.append(" # Unpack data qubits") + _append_szz_data_unpack(lines, " ") + szz_syndrome_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) + + lines.append(" # Allocate ancilla qubits (one per stabilizer)") lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + if interaction_basis == "cx": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + + for rnd_idx, rnd_gates in enumerate(rounds): + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for stab_type, stab_idx, data_q in rnd_gates: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") - for rnd_idx, rnd_gates in enumerate(rounds): lines.append("") - lines.append(f" # Round {rnd_idx + 1}") - for stab_type, stab_idx, data_q in rnd_gates: - if stab_type == "X": - lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + else: + lines.append("") + lines.append(" # Hadamard on SZZ ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + lines.extend(f" h(az{stab.index})" for stab in geom.z_stabilizers) - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + for rnd_idx, rnd_gates in enumerate(rounds): + _append_szz_layer( + lines, + " ", + rnd_idx, + list(rnd_gates), + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=szz_syndrome_pending_by_data, + host_label_scope="syndrome_extraction", + ) + + lines.append("") + lines.append(" # Hadamard on SZZ ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + lines.extend(f" h(az{stab.index})" for stab in geom.z_stabilizers) lines.append("") lines.append(" # Measure ancillas") idx = 0 for stab in geom.x_stabilizers: - lines.append(f" sx{stab.index} = measure(ax{stab.index})") + if canonical_frame_output: + raw_var = f"sx{stab.index}_raw" + flip_var = f"sx{stab.index}_flip" + flip_expr = _xor_expr(f"frame_z[{q}]" for q in stab.data_qubits) + lines.append(f" {raw_var} = measure(ax{stab.index})") + lines.append(f" {flip_var} = {flip_expr}") + lines.append(f" sx{stab.index} = {raw_var} != {flip_var}") + lines.append(f' result("raw:sx{stab.index}:bit:{idx}", {raw_var})') + else: + lines.append(f" sx{stab.index} = measure(ax{stab.index})") lines.append(f' result("sx{stab.index}:meas:{idx}", sx{stab.index})') idx += 1 for stab in geom.z_stabilizers: - lines.append(f" sz{stab.index} = measure(az{stab.index})") + if canonical_frame_output: + raw_var = f"sz{stab.index}_raw" + flip_var = f"sz{stab.index}_flip" + flip_expr = _xor_expr(f"frame_x[{q}]" for q in stab.data_qubits) + lines.append(f" {raw_var} = measure(az{stab.index})") + lines.append(f" {flip_var} = {flip_expr}") + lines.append(f" sz{stab.index} = {raw_var} != {flip_var}") + lines.append(f' result("raw:sz{stab.index}:bit:{idx}", {raw_var})') + else: + lines.append(f" sz{stab.index} = measure(az{stab.index})") lines.append(f' result("sz{stab.index}:meas:{idx}", sz{stab.index})') idx += 1 else: # Constrained: stabilizer-batched. The batch sequence is the - # shared `batched_stabilizers(patch, effective_budget)` so the + # shared `batched_stabilizers(patch, effective_budget, schedule=...)` so the # abstract circuit's measurement order matches by construction. - batches = batched_stabilizers(patch, effective_budget) + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) lines.append( f' """Extract full syndrome in {len(batches)} ancilla-reuse batches (budget={effective_budget})."""', ) + if interaction_basis == "szz": + lines.append(" # Unpack data qubits") + _append_szz_data_unpack(lines, " ") + szz_syndrome_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) idx = 0 for batch_idx, batch in enumerate(batches): lines.append("") @@ -233,13 +1042,18 @@ def generate_guppy_source( batch_anc_var[(stab_type, stab_idx)] = var lines.append(f" {var} = qubit()") - x_in_batch = [(t, i) for (t, i) in batch if t == "X"] - if x_in_batch: - lines.append(" # Hadamard on X ancillas in this batch") - for stab_type, stab_idx in x_in_batch: + if interaction_basis == "cx": + x_in_batch = [(t, i) for (t, i) in batch if t == "X"] + if x_in_batch: + lines.append(" # Hadamard on X ancillas in this batch") + for stab_type, stab_idx in x_in_batch: + lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") + else: + lines.append(" # Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") - # Filter the full CX schedule to just this batch's stabilizers. + # Filter the full interaction schedule to just this batch's stabilizers. batch_keys = set(batch_anc_var.keys()) for rnd_idx, rnd_gates in enumerate(rounds): rnd_in_batch = [ @@ -251,17 +1065,36 @@ def generate_guppy_source( continue lines.append("") lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") - for stab_type, stab_idx, data_q in rnd_in_batch: - anc = batch_anc_var[(stab_type, stab_idx)] - if stab_type == "X": - lines.append(f" cx({anc}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], {anc})") - - if x_in_batch: + if interaction_basis == "cx": + for stab_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(stab_type, stab_idx)] + if stab_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + else: + _append_szz_layer( + lines, + " ", + rnd_idx, + rnd_in_batch, + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[(stab_type, stab_idx)], + _szz_data_expr, + pending_by_data=szz_syndrome_pending_by_data, + host_label_scope="syndrome_extraction", + ) + + if interaction_basis == "cx": + x_in_batch = [(t, i) for (t, i) in batch if t == "X"] + if x_in_batch: + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for stab_type, stab_idx in x_in_batch: + lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") + else: lines.append("") - lines.append(" # Hadamard on X ancillas in this batch") - for stab_type, stab_idx in x_in_batch: + lines.append(" # Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") lines.append("") @@ -277,17 +1110,214 @@ def generate_guppy_source( x_calls = ", ".join(f"sx{s.index}" for s in geom.x_stabilizers) z_calls = ", ".join(f"sz{s.index}" for s in geom.z_stabilizers) - lines.extend( - [ - "", - f" synx = array({x_calls})", - f" synz = array({z_calls})", - "", - f" return Syndrome_{dx}x{dz}(synx, synz)", - "", - "", - ], - ) + lines.extend(["", f" synx = array({x_calls})", f" synz = array({z_calls})", ""]) + if interaction_basis == "szz": + if szz_syndrome_pending_by_data is None: + msg = "internal error: SZZ syndrome extraction did not initialize data-frame state" + raise ValueError(msg) + _append_szz_flush_data_frame(lines, " ", szz_syndrome_pending_by_data, reason="syndrome return") + lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + lines.append(f" return surf, Syndrome_{dx}x{dz}(synx, synz)") + else: + lines.append(f" return Syndrome_{dx}x{dz}(synx, synz)") + lines.extend(["", ""]) + + def append_init_syndrome_function(function_name: str, stab_type: str) -> None: + """Append a basis-prep syndrome-establishment helper.""" + if stab_type == "X": + stabs = list(geom.x_stabilizers) + return_type = f"array[bool, {num_x_stab}]" + return_calls = ", ".join(f"sx{s.index}" for s in stabs) + doc = "Establish initial X stabilizer signs after Z-basis data prep." + else: + stabs = list(geom.z_stabilizers) + return_type = f"array[bool, {num_z_stab}]" + return_calls = ", ".join(f"sz{s.index}" for s in stabs) + doc = "Establish initial Z stabilizer signs after X-basis data prep." + + lines.extend( + [ + "", + "", + "@guppy", + ( + f"def {function_name}(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, {return_type}]:" + if interaction_basis == "szz" + else f"def {function_name}(surf: SurfaceCode_{dx}x{dz}) -> {return_type}:" + ), + f' """{doc}"""', + ], + ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") + szz_init_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) + + if not constrained: + if stab_type == "X": + lines.extend(f" ax{stab.index} = qubit()" for stab in stabs) + else: + lines.extend(f" az{stab.index} = qubit()" for stab in stabs) + + if interaction_basis == "cx": + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] + if not filtered: + continue + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for _stab_type, stab_idx, data_q in filtered: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.append("") + lines.append(" # Hadamard on SZZ ancillas") + if stab_type == "X": + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.extend(f" h(az{stab.index})" for stab in stabs) + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] + if not filtered: + continue + _append_szz_layer( + lines, + " ", + rnd_idx, + filtered, + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=szz_init_pending_by_data, + host_label_scope=function_name, + ) + + lines.append("") + lines.append(" # Hadamard on SZZ ancillas") + if stab_type == "X": + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.extend(f" h(az{stab.index})" for stab in stabs) + + lines.append("") + lines.append(" # Measure init ancillas") + for idx, stab in enumerate(stabs): + if stab_type == "X": + lines.append(f" sx{stab.index} = measure(ax{stab.index})") + lines.append(f' result("sx{stab.index}:init:meas:{idx}", sx{stab.index})') + else: + lines.append(f" sz{stab.index} = measure(az{stab.index})") + lines.append(f' result("sz{stab.index}:init:meas:{idx}", sz{stab.index})') + else: + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) + idx = 0 + for batch_idx, batch in enumerate(batches): + init_batch = [(t, i) for t, i in batch if t == stab_type] + if not init_batch: + continue + lines.append("") + lines.append(f" # Batch {batch_idx + 1}/{len(batches)} of {stab_type} stabilizers") + + batch_anc_var: dict[tuple[str, int], str] = {} + for pos, (selected_type, stab_idx) in enumerate(init_batch): + var = f"_init_a_b{batch_idx}_p{pos}" + batch_anc_var[(selected_type, stab_idx)] = var + lines.append(f" {var} = qubit()") + + if interaction_basis == "cx": + if stab_type == "X": + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + else: + lines.append(" # Hadamard on SZZ ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + + batch_keys = set(batch_anc_var.keys()) + for rnd_idx, rnd_gates in enumerate(rounds): + rnd_in_batch = [ + (selected_type, stab_idx, data_q) + for selected_type, stab_idx, data_q in rnd_gates + if (selected_type, stab_idx) in batch_keys + ] + if not rnd_in_batch: + continue + lines.append("") + lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") + if interaction_basis == "cx": + for selected_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(selected_type, stab_idx)] + if selected_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + else: + _append_szz_layer( + lines, + " ", + rnd_idx, + rnd_in_batch, + lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (selected_type, stab_idx) + ], + _szz_data_expr, + pending_by_data=szz_init_pending_by_data, + host_label_scope=function_name, + ) + + if interaction_basis == "cx": + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + else: + lines.append("") + lines.append(" # Hadamard on SZZ ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + + lines.append("") + lines.append(f" # Measure init batch {batch_idx + 1} ancillas") + for selected_type, stab_idx in init_batch: + anc = batch_anc_var[(selected_type, stab_idx)] + syn_var = f"sx{stab_idx}" if selected_type == "X" else f"sz{stab_idx}" + lines.append(f" {syn_var} = measure({anc})") + lines.append(f' result("{syn_var}:init:meas:{idx}", {syn_var})') + idx += 1 + + lines.append("") + if interaction_basis == "szz": + if szz_init_pending_by_data is None: + msg = "internal error: SZZ init helper did not initialize data-frame state" + raise ValueError(msg) + _append_szz_flush_data_frame(lines, " ", szz_init_pending_by_data, reason=f"{function_name} return") + lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + lines.append(f" return surf, array({return_calls})") + else: + lines.append(f" return array({return_calls})") + + append_init_syndrome_function("init_z_basis", "X") + append_init_syndrome_function("init_x_basis", "Z") # Generate measurement lines.extend( @@ -297,19 +1327,46 @@ def generate_guppy_source( "@guppy", f"def measure_z_basis(surf: SurfaceCode_{dx}x{dz} @ owned) -> array[bool, {num_data}]:", ' """Destructively measure in Z basis."""', - " return measure_array(surf.data)", + ], + ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") + for i in range(num_data): + _append_szz_axis_rotation_from_z( + lines, + " ", + _szz_physical_axis_for_memory_data("Z", i), + f"d{i}", + ) + z_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) + lines.append(f" return array({z_meas})") + else: + lines.append(" return measure_array(surf.data)") + lines.extend( + [ "", "", "@guppy", f"def measure_x_basis(surf: SurfaceCode_{dx}x{dz} @ owned) -> array[bool, {num_data}]:", ' """Destructively measure in X basis."""', - f" for i in range({num_data}):", - " h(surf.data[i])", - " return measure_array(surf.data)", - "", - "", ], ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") + for i in range(num_data): + _append_szz_axis_rotation_from_z( + lines, + " ", + _szz_physical_axis_for_memory_data("X", i), + f"d{i}", + ) + x_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) + lines.append(f" return array({x_meas})") + else: + lines.append(f" for i in range({num_data}):") + lines.append(" h(surf.data[i])") + lines.append(" return measure_array(surf.data)") + lines.extend(["", ""]) # Generate logical operators logical_x_qubits = list(geom.logical_x.data_qubits) if geom.logical_x else [] @@ -324,7 +1381,12 @@ def generate_guppy_source( ' """Apply logical X (string along left edge)."""', ], ) - lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) + if interaction_basis == "szz": + for q in logical_x_qubits: + logical_x_axis = _szz_physical_axis_for_logical_data("X", q) + _append_szz_logical_pauli(lines, " ", logical_x_axis, f"surf.d{q}") + else: + lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) lines.extend( [ @@ -333,11 +1395,14 @@ def generate_guppy_source( "@guppy", f"def apply_logical_z(surf: SurfaceCode_{dx}x{dz}) -> None:", ' """Apply logical Z (string along top edge)."""', - " from guppylang.std.quantum import z", - "", ], ) - lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) + if interaction_basis == "szz": + for q in logical_z_qubits: + logical_z_axis = _szz_physical_axis_for_logical_data("Z", q) + _append_szz_logical_pauli(lines, " ", logical_z_axis, f"surf.d{q}") + else: + lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) lines.extend( [ @@ -346,56 +1411,847 @@ def generate_guppy_source( ], ) - # Generate memory experiment factories - lines.extend( - [ - "# === Memory Experiments ===", - "", - "def make_memory_z(num_rounds: int):", - ' """Create Z-basis memory experiment."""', - " from guppylang.std.builtins import comptime", - "", - " @guppy", - " def memory_z() -> None:", - f' """Z-basis memory experiment for dx={dx}, dz={dz}."""', - " surf = prep_z_basis()", - "", - " for _t in range(comptime(num_rounds)):", - " syn = syndrome_extraction(surf)", - ' result("synx", syn.synx)', - ' result("synz", syn.synz)', + def _append_inline_szz_syndrome_extraction( + target: list[str], + indent: str, + *, + host_label_scope: str, + ) -> None: + """Append one SZZ syndrome-extraction body with unique hosted ids.""" + if interaction_basis != "szz": + msg = "inline SZZ syndrome extraction requires interaction_basis='szz'" + raise ValueError(msg) + target.append(f"{indent}# Inline SZZ syndrome extraction ({host_label_scope})") + target.append(f"{indent}# Unpack data qubits") + _append_szz_data_unpack(target, indent) + pending_by_data = dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) + + if not constrained: + target.append(f"{indent}# Allocate ancilla qubits (one per stabilizer)") + target.extend(f"{indent}ax{stab.index} = qubit()" for stab in geom.x_stabilizers) + target.extend(f"{indent}az{stab.index} = qubit()" for stab in geom.z_stabilizers) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas") + target.extend(f"{indent}h(ax{stab.index})" for stab in geom.x_stabilizers) + target.extend(f"{indent}h(az{stab.index})" for stab in geom.z_stabilizers) + + for rnd_idx, rnd_gates in enumerate(rounds): + _append_szz_layer( + target, + indent, + rnd_idx, + list(rnd_gates), + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=pending_by_data, + host_label_scope=host_label_scope, + ) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas") + target.extend(f"{indent}h(ax{stab.index})" for stab in geom.x_stabilizers) + target.extend(f"{indent}h(az{stab.index})" for stab in geom.z_stabilizers) + + target.append("") + target.append(f"{indent}# Measure ancillas") + idx = 0 + for stab in geom.x_stabilizers: + target.append(f"{indent}sx{stab.index} = measure(ax{stab.index})") + target.append(f'{indent}result("sx{stab.index}:meas:{idx}", sx{stab.index})') + idx += 1 + for stab in geom.z_stabilizers: + target.append(f"{indent}sz{stab.index} = measure(az{stab.index})") + target.append(f'{indent}result("sz{stab.index}:meas:{idx}", sz{stab.index})') + idx += 1 + else: + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) + idx = 0 + for batch_idx, batch in enumerate(batches): + target.append("") + target.append(f"{indent}# Batch {batch_idx + 1}/{len(batches)} of stabilizers") + batch_anc_var: dict[tuple[str, int], str] = {} + for pos, (stab_type, stab_idx) in enumerate(batch): + var = f"_a_b{batch_idx}_p{pos}" + batch_anc_var[(stab_type, stab_idx)] = var + target.append(f"{indent}{var} = qubit()") + + target.append(f"{indent}# Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: + target.append(f"{indent}h({batch_anc_var[(stab_type, stab_idx)]})") + + batch_keys = set(batch_anc_var.keys()) + for rnd_idx, rnd_gates in enumerate(rounds): + rnd_in_batch = [ + (stab_type, stab_idx, data_q) + for stab_type, stab_idx, data_q in rnd_gates + if (stab_type, stab_idx) in batch_keys + ] + if not rnd_in_batch: + continue + target.append("") + target.append(f"{indent}# Batch {batch_idx + 1} round {rnd_idx + 1}") + _append_szz_layer( + target, + indent, + rnd_idx, + rnd_in_batch, + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[(stab_type, stab_idx)], + _szz_data_expr, + pending_by_data=pending_by_data, + host_label_scope=host_label_scope, + ) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: + target.append(f"{indent}h({batch_anc_var[(stab_type, stab_idx)]})") + + target.append("") + target.append(f"{indent}# Measure batch {batch_idx + 1} ancillas") + for stab_type, stab_idx in batch: + anc = batch_anc_var[(stab_type, stab_idx)] + syn_var = f"sx{stab_idx}" if stab_type == "X" else f"sz{stab_idx}" + target.append(f"{indent}{syn_var} = measure({anc})") + target.append(f'{indent}result("{syn_var}:meas:{idx}", {syn_var})') + idx += 1 + + x_calls = ", ".join(f"sx{s.index}" for s in geom.x_stabilizers) + z_calls = ", ".join(f"sz{s.index}" for s in geom.z_stabilizers) + target.extend(["", f"{indent}synx = array({x_calls})", f"{indent}synz = array({z_calls})", ""]) + _append_szz_flush_data_frame( + target, + indent, + pending_by_data, + reason=f"{host_label_scope} inline syndrome", + ) + target.append(f"{indent}surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + + def _render_plain_szz_round_helper(round_idx: int) -> list[str]: + helper_name = f"syndrome_extraction_memory_r{round_idx}" + body = [ "", - " final = measure_z_basis(surf)", - ' result("final", final)', "", - " return memory_z", + "@guppy", + ( + f"def {helper_name}(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, Syndrome_{dx}x{dz}]:" + ), + f' """Extract counted SZZ syndrome round {round_idx} with round-scoped hosted metadata."""', + ] + _append_inline_szz_syndrome_extraction( + body, + " ", + host_label_scope=f"memory_r{round_idx}", + ) + body.append(f" return surf, Syndrome_{dx}x{dz}(synx, synz)") + return body + + def _render_plain_szz_memory_block( + basis: str, + basis_upper: str, + rendered_num_rounds: int, + ) -> list[str]: + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + body: list[str] = [ + ( + f' """{basis_upper}-basis SZZ memory experiment for ' + f"dx={dx}, dz={dz}, num_rounds={rendered_num_rounds}." + '"""' + ), + f" surf = prep_{basis}_basis()", + f" surf, init_syn = {init_func}(surf)", + f' result("{init_tag}", init_syn)', "", + ] + for round_idx in range(rendered_num_rounds): + body.append(f" # === Counted syndrome round {round_idx} ===") + body.append(f" surf, syn = syndrome_extraction_memory_r{round_idx}(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append("") + + body.extend( + [ + f" final = measure_{basis}_basis(surf)", + ' result("final", final)', + ], + ) + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis SZZ memory experiment.', "", - "def make_memory_x(num_rounds: int):", - ' """Create X-basis memory experiment."""', - " from guppylang.std.builtins import comptime", + f" num_rounds must equal {rendered_num_rounds} -- the body was unrolled at", + " source-generation time so hosted-operation metadata is unique per", + " counted syndrome round. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {rendered_num_rounds}:", + ( + f' msg = f"this generated module was unrolled for ' + f'num_rounds={rendered_num_rounds}, got {{num_rounds!r}}"' + ), + " raise ValueError(msg)", "", " @guppy", - " def memory_x() -> None:", - f' """X-basis memory experiment for dx={dx}, dz={dz}."""', - " surf = prep_x_basis()", + f" def memory_{basis}() -> None:", + *body, "", - " for _t in range(comptime(num_rounds)):", - " syn = syndrome_extraction(surf)", - ' result("synx", syn.synx)', - ' result("synz", syn.synz)', + f" return memory_{basis}", "", - " final = measure_x_basis(surf)", - ' result("final", final)', "", - " return memory_x", - "", - ], - ) + ] + + # Generate memory experiment factories. Plain SZZ memory programs are + # unrolled when the round count is available so hosted metadata identifies + # the concrete counted syndrome round instead of the reusable helper body. + if twirl is None and interaction_basis == "szz" and num_rounds is not None: + lines.extend(["# === Counted SZZ Syndrome Helpers ==="]) + for round_idx in range(num_rounds): + lines.extend(_render_plain_szz_round_helper(round_idx)) + lines.extend(["# === Memory Experiments ===", ""]) + for basis, basis_upper in (("z", "Z"), ("x", "X")): + lines.extend(_render_plain_szz_memory_block(basis, basis_upper, num_rounds)) + else: + lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds, interaction_basis)) return "\n".join(lines) +def _xor_expr(terms: object) -> str: + """Return a Guppy bool XOR expression for the given source terms.""" + parts = list(terms) + if not parts: + return "False" + expr = parts[0] + for part in parts[1:]: + expr = f"({expr} != {part})" + return str(expr) + + +def _render_memory_experiments( + patch: "SurfacePatch", + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig | None", + rng: "GuppyRngMaskConfig | None", + num_rounds: int | None, + interaction_basis: str, +) -> list[str]: + """Render both memory factory functions.""" + lines = [ + "# === Memory Experiments ===", + "", + ] + for basis, basis_upper in (("z", "Z"), ("x", "X")): + if twirl is None: + lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz, interaction_basis)) + else: + if rng is None or num_rounds is None: + msg = "twirled memory rendering requires both rng and num_rounds" + raise ValueError(msg) + if twirl.site_schedule == "before_two_qubit_gate": + lines.extend( + _render_gate_local_twirled_memory_block( + patch, + basis, + basis_upper, + dx, + dz, + num_data, + twirl, + rng, + num_rounds, + ), + ) + else: + lines.extend( + _render_twirled_memory_block( + basis, + basis_upper, + dx, + dz, + num_data, + twirl, + rng, + num_rounds, + ), + ) + return lines + + +def _render_plain_memory_block( + basis: str, + basis_upper: str, + dx: int, + dz: int, + interaction_basis: str, +) -> list[str]: + """Render the vanilla handoff memory factory for one basis.""" + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + if interaction_basis == "szz": + init_line = f" surf, init_syn = {init_func}(surf)" + syndrome_line = " surf, syn = syndrome_extraction(surf)" + else: + init_line = f" init_syn = {init_func}(surf)" + syndrome_line = " syn = syndrome_extraction(surf)" + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis memory experiment."""', + " from guppylang.std.builtins import comptime", + "", + " @guppy", + f" def memory_{basis}() -> None:", + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}."""', + f" surf = prep_{basis}_basis()", + init_line, + f' result("{init_tag}", init_syn)', + "", + " for _t in range(comptime(num_rounds)):", + syndrome_line, + ' result("synx", syn.synx)', + ' result("synz", syn.synz)', + "", + f" final = measure_{basis}_basis(surf)", + ' result("final", final)', + "", + f" return memory_{basis}", + "", + "", + ] + + +def _twirl_activation_threshold(twirl: "TwirlConfig") -> int: + """Return the full-width PCG threshold for a twirl activation probability.""" + probability = float(twirl.twirl_probability) + # Validation lives on TwirlConfig; clamp the mathematically exact endpoint + # after float multiplication so f=1.0 always activates every 32-bit draw. + threshold = int(probability * (1 << 32)) + return min(max(threshold, 0), 1 << 32) + + +def _emit_scaled_activation_tags(twirl: "TwirlConfig") -> bool: + """Whether generated source needs explicit activation side-band tags.""" + return float(twirl.twirl_probability) != 1.0 + + +def _append_pauli_draw( + lines: list[str], + indent: str, + *, + active_var: str, + draw_var: str, + m_var: str, + qubit_expr: str, + threshold: int, +) -> None: + """Emit fixed-consumption activation + Pauli draw code for one twirl operand.""" + lines.append(f"{indent}rng_state, active_draw_{m_var} = _pcg32_next32(rng_state, rng_inc)") + lines.append(f"{indent}{active_var} = active_draw_{m_var} < nat({threshold})") + lines.append(f"{indent}rng_state, {draw_var} = _pcg32_next4(rng_state, rng_inc)") + lines.append(f"{indent}{m_var} = 0") + lines.append(f"{indent}if {active_var}:") + lines.append(f"{indent} {m_var} = {draw_var}") + lines.append(f"{indent}if {m_var} == 1:") + lines.append(f"{indent} x({qubit_expr})") + lines.append(f"{indent}if {m_var} == 2:") + lines.append(f"{indent} y({qubit_expr})") + lines.append(f"{indent}if {m_var} == 3:") + lines.append(f"{indent} z({qubit_expr})") + + +def _render_twirled_memory_block( + basis: str, + basis_upper: str, + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig", + rng: "GuppyRngMaskConfig", + num_rounds: int, +) -> list[str]: + """Render a Python-time unrolled twirled memory factory.""" + from pecos.qec.surface._twirl_sites import num_twirl_sites, pauli_active_round_tag, pauli_mask_round_tag + + seed = int(rng.seed) + canonical_frame_output = twirl.frame_output == "canonical" + activation_threshold = _twirl_activation_threshold(twirl) + emit_activation_tags = _emit_scaled_activation_tags(twirl) + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + body: list[str] = [ + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}, num_rounds={num_rounds} (twirled)."""', + f" surf = prep_{basis}_basis()", + " # RNG seed is structural -- changing it does not invalidate the", + " # abstract DEM / topology cache, only the per-shot mask buffer.", + f" rng_state, rng_inc = seeded_pcg32_with_quantum_entropy({seed})", + f' result("frame_mode:{twirl.frame_output}", True)', + f" init_syn = {init_func}(surf)", + f' result("{init_tag}", init_syn)', + "", + ] + if canonical_frame_output: + for q in range(num_data): + body.append(f" fx_{q} = False") + body.append(f" fz_{q} = False") + body.append("") + + # Emit num_rounds - 1 twirled rounds, then one final untwirled round. + n_twirl = num_twirl_sites(num_rounds) + for r in range(n_twirl): + body.append(f" # === Round {r} (twirled) ===") + if canonical_frame_output: + frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) + frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) + body.append( + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))", + ) + else: + body.append(" syn = syndrome_extraction(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append(" # Pauli twirl site between this round and the next.") + for q in range(num_data): + _append_pauli_draw( + body, + " ", + active_var=f"active_{r}_{q}", + draw_var=f"m_draw_{r}_{q}", + m_var=f"m_{r}_{q}", + qubit_expr=f"surf.data[{q}]", + threshold=activation_threshold, + ) + body.append(f" lo_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 3)") + body.append(f" hi_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)") + if canonical_frame_output: + body.append( + f" twx_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 2)", + ) + body.append( + f" twz_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)", + ) + body.append(f" fx_{q} = fx_{q} != twx_{r}_{q}") + body.append(f" fz_{q} = fz_{q} != twz_{r}_{q}") + elements = ", ".join(f"lo_{r}_{q}, hi_{r}_{q}" for q in range(num_data)) + tag = pauli_mask_round_tag(r) + body.append(f' result("{tag}", array({elements}))') + if emit_activation_tags: + active_elements = ", ".join(f"active_{r}_{q}" for q in range(num_data)) + active_tag = pauli_active_round_tag(r) + body.append(f' result("{active_tag}", array({active_elements}))') + body.append("") + + if num_rounds > 0: + body.append(f" # === Round {num_rounds - 1} (final, no twirl after) ===") + if canonical_frame_output: + frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) + frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) + body.append( + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))", + ) + else: + body.append(" syn = syndrome_extraction(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append("") + + if canonical_frame_output: + body.append(f" final_raw = measure_{basis}_basis(surf)") + body.append(' result("raw:final", final_raw)') + for q in range(num_data): + flip_var = f"fx_{q}" if basis == "z" else f"fz_{q}" + body.append(f" final_{q} = final_raw[{q}] != {flip_var}") + final_elements = ", ".join(f"final_{q}" for q in range(num_data)) + body.append(f' result("final", array({final_elements}))') + else: + body.append(f" final = measure_{basis}_basis(surf)") + body.append(' result("final", final)') + + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis twirled memory experiment.', + "", + f" num_rounds must equal {num_rounds} -- the body was unrolled at", + " source-generation time. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {num_rounds}:", + f' msg = f"this generated module was unrolled for num_rounds={num_rounds}, got {{num_rounds!r}}"', + " raise ValueError(msg)", + "", + " @guppy", + f" def memory_{basis}() -> None:", + *body, + "", + f" return memory_{basis}", + "", + "", + ] + + +def _frame_vars(prefix: str, idx: int) -> tuple[str, str]: + return f"frame_x_{prefix}{idx}", f"frame_z_{prefix}{idx}" + + +def _append_frame_swap(lines: list[str], indent: str, x_var: str, z_var: str, tmp_var: str) -> None: + lines.append(f"{indent}{tmp_var} = {x_var}") + lines.append(f"{indent}{x_var} = {z_var}") + lines.append(f"{indent}{z_var} = {tmp_var}") + + +def _append_gate_local_draw( + lines: list[str], + indent: str, + *, + site_idx: int, + operand_idx: int, + qubit_expr: str, + frame_vars: tuple[str, str] | None, + threshold: int, +) -> tuple[str, str, str]: + m_var = f"m_g{site_idx}_o{operand_idx}" + active_var = f"active_g{site_idx}_o{operand_idx}" + draw_var = f"m_draw_g{site_idx}_o{operand_idx}" + lo_var = f"lo_g{site_idx}_o{operand_idx}" + hi_var = f"hi_g{site_idx}_o{operand_idx}" + _append_pauli_draw( + lines, + indent, + active_var=active_var, + draw_var=draw_var, + m_var=m_var, + qubit_expr=qubit_expr, + threshold=threshold, + ) + lines.append(f"{indent}{lo_var} = ({m_var} == 1) | ({m_var} == 3)") + lines.append(f"{indent}{hi_var} = ({m_var} == 2) | ({m_var} == 3)") + if frame_vars is not None: + x_frame, z_frame = frame_vars + twx_var = f"twx_g{site_idx}_o{operand_idx}" + twz_var = f"twz_g{site_idx}_o{operand_idx}" + lines.append(f"{indent}{twx_var} = ({m_var} == 1) | ({m_var} == 2)") + lines.append(f"{indent}{twz_var} = ({m_var} == 2) | ({m_var} == 3)") + lines.append(f"{indent}{x_frame} = {x_frame} != {twx_var}") + lines.append(f"{indent}{z_frame} = {z_frame} != {twz_var}") + return lo_var, hi_var, active_var + + +def _append_gate_local_layer( + lines: list[str], + indent: str, + *, + site_idx: int, + cx_ops: list[tuple[str, str, tuple[str, str] | None, tuple[str, str] | None]], + threshold: int, + emit_activation_tags: bool, +) -> int: + """Emit all twirl draws before a parallel CX layer, then the CX layer.""" + from pecos.qec.surface._twirl_sites import pauli_active_gate_tag, pauli_mask_gate_tag + + for control_expr, target_expr, control_frame, target_frame in cx_ops: + lo0, hi0, active0 = _append_gate_local_draw( + lines, + indent, + site_idx=site_idx, + operand_idx=0, + qubit_expr=control_expr, + frame_vars=control_frame, + threshold=threshold, + ) + lo1, hi1, active1 = _append_gate_local_draw( + lines, + indent, + site_idx=site_idx, + operand_idx=1, + qubit_expr=target_expr, + frame_vars=target_frame, + threshold=threshold, + ) + tag = pauli_mask_gate_tag(site_idx) + lines.append(f'{indent}result("{tag}", array({lo0}, {hi0}, {lo1}, {hi1}))') + if emit_activation_tags: + active_tag = pauli_active_gate_tag(site_idx) + lines.append(f'{indent}result("{active_tag}", array({active0}, {active1}))') + site_idx += 1 + + for control_expr, target_expr, control_frame, target_frame in cx_ops: + lines.append(f"{indent}cx({control_expr}, {target_expr})") + if control_frame is not None and target_frame is not None: + control_x, control_z = control_frame + target_x, target_z = target_frame + lines.append(f"{indent}{target_x} = {target_x} != {control_x}") + lines.append(f"{indent}{control_z} = {control_z} != {target_z}") + return site_idx + + +def _append_gate_local_measure( + lines: list[str], + indent: str, + *, + bit_var: str, + qubit_expr: str, + result_tag: str, + raw_tag: str, + frame_vars: tuple[str, str] | None, +) -> None: + if frame_vars is None: + lines.append(f"{indent}{bit_var} = measure({qubit_expr})") + else: + raw_var = f"{bit_var}_raw" + frame_x, _frame_z = frame_vars + lines.append(f"{indent}{raw_var} = measure({qubit_expr})") + lines.append(f"{indent}{bit_var} = {raw_var} != {frame_x}") + lines.append(f'{indent}result("{raw_tag}", {raw_var})') + lines.append(f'{indent}result("{result_tag}", {bit_var})') + + +def _render_gate_local_twirled_memory_block( + patch: "SurfacePatch", + basis: str, + basis_upper: str, + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig", + rng: "GuppyRngMaskConfig", + num_rounds: int, +) -> list[str]: + """Render a gate-local twirled memory factory.""" + seed = int(rng.seed) + canonical_frame_output = twirl.frame_output == "canonical" + activation_threshold = _twirl_activation_threshold(twirl) + emit_activation_tags = _emit_scaled_activation_tags(twirl) + geom = patch.geometry + rounds = compute_cnot_schedule(patch) + init_stab_type = "X" if basis == "z" else "Z" + init_tag = "init_synx" if basis == "z" else "init_synz" + indent = " " + site_idx = 0 + + data_frames = [_frame_vars("d", q) for q in range(num_data)] + x_anc_frames = [_frame_vars("ax", stab.index) for stab in geom.x_stabilizers] + z_anc_frames = [_frame_vars("az", stab.index) for stab in geom.z_stabilizers] + + def data_expr(q: int) -> str: + return f"surf.data[{q}]" + + def anc_expr(stab_type: str, stab_idx: int) -> str: + return f"ax{stab_idx}" if stab_type == "X" else f"az{stab_idx}" + + def anc_frame(stab_type: str, stab_idx: int) -> tuple[str, str] | None: + if not canonical_frame_output: + return None + return x_anc_frames[stab_idx] if stab_type == "X" else z_anc_frames[stab_idx] + + def data_frame(q: int) -> tuple[str, str] | None: + return data_frames[q] if canonical_frame_output else None + + def cx_tuple( + stab_type: str, + stab_idx: int, + data_q: int, + ) -> tuple[str, str, tuple[str, str] | None, tuple[str, str] | None]: + if stab_type == "X": + return ( + anc_expr(stab_type, stab_idx), + data_expr(data_q), + anc_frame(stab_type, stab_idx), + data_frame(data_q), + ) + return ( + data_expr(data_q), + anc_expr(stab_type, stab_idx), + data_frame(data_q), + anc_frame(stab_type, stab_idx), + ) + + body: list[str] = [ + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}, ' + f'num_rounds={num_rounds} (gate-local twirled)."""', + f" surf = prep_{basis}_basis()", + " # RNG seed is structural -- changing it does not invalidate the", + " # abstract DEM / topology cache, only the per-shot mask buffer.", + f" rng_state, rng_inc = seeded_pcg32_with_quantum_entropy({seed})", + f' result("frame_mode:{twirl.frame_output}", True)', + ] + if canonical_frame_output: + for q in range(num_data): + frame_x, frame_z = data_frames[q] + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + body.append("") + + # Initial syndrome establishment for the complementary stabilizer family. + init_stabs = list(geom.x_stabilizers if init_stab_type == "X" else geom.z_stabilizers) + for stab in init_stabs: + body.append(f"{indent}{anc_expr(init_stab_type, stab.index)} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame(init_stab_type, stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + if init_stab_type == "X": + body.append("") + for stab in init_stabs: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_init_ax{stab.index}") + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == init_stab_type] + if not filtered: + continue + body.append("") + body.append(f"{indent}# Init CX round {rnd_idx + 1}") + cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in filtered] + site_idx = _append_gate_local_layer( + body, + indent, + site_idx=site_idx, + cx_ops=cx_ops, + threshold=activation_threshold, + emit_activation_tags=emit_activation_tags, + ) + + if init_stab_type == "X": + body.append("") + for stab in init_stabs: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_init2_ax{stab.index}") + + body.append("") + init_bits: list[str] = [] + for idx, stab in enumerate(init_stabs): + bit_var = f"s{init_stab_type.lower()}{stab.index}_init" + init_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=anc_expr(init_stab_type, stab.index), + result_tag=f"s{init_stab_type.lower()}{stab.index}:init:meas:{idx}", + raw_tag=f"raw:s{init_stab_type.lower()}{stab.index}:init:bit:{idx}", + frame_vars=anc_frame(init_stab_type, stab.index), + ) + body.append(f'{indent}result("{init_tag}", array({", ".join(init_bits)}))') + body.append("") + + for round_idx in range(num_rounds): + body.append(f"{indent}# === Round {round_idx} (gate-local twirled) ===") + for stab in geom.x_stabilizers: + body.append(f"{indent}ax{stab.index} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + for stab in geom.z_stabilizers: + body.append(f"{indent}az{stab.index} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame("Z", stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + for stab in geom.x_stabilizers: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_r{round_idx}_ax{stab.index}") + + for rnd_idx, rnd_gates in enumerate(rounds): + body.append("") + body.append(f"{indent}# Round {round_idx} CX layer {rnd_idx + 1}") + cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in rnd_gates] + site_idx = _append_gate_local_layer( + body, + indent, + site_idx=site_idx, + cx_ops=cx_ops, + threshold=activation_threshold, + emit_activation_tags=emit_activation_tags, + ) + + for stab in geom.x_stabilizers: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h2_r{round_idx}_ax{stab.index}") + + sx_bits: list[str] = [] + sz_bits: list[str] = [] + meas_idx = 0 + for stab in geom.x_stabilizers: + bit_var = f"sx{stab.index}_r{round_idx}" + sx_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=f"ax{stab.index}", + result_tag=f"sx{stab.index}:meas:{meas_idx}", + raw_tag=f"raw:sx{stab.index}:bit:{meas_idx}", + frame_vars=anc_frame("X", stab.index), + ) + meas_idx += 1 + for stab in geom.z_stabilizers: + bit_var = f"sz{stab.index}_r{round_idx}" + sz_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=f"az{stab.index}", + result_tag=f"sz{stab.index}:meas:{meas_idx}", + raw_tag=f"raw:sz{stab.index}:bit:{meas_idx}", + frame_vars=anc_frame("Z", stab.index), + ) + meas_idx += 1 + body.append(f'{indent}result("synx", array({", ".join(sx_bits)}))') + body.append(f'{indent}result("synz", array({", ".join(sz_bits)}))') + body.append("") + + if canonical_frame_output: + if basis == "x": + for q in range(num_data): + body.append(f"{indent}h(surf.data[{q}])") + frame_x, frame_z = data_frames[q] + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_final_d{q}") + body.append(f"{indent}final_raw = measure_array(surf.data)") + body.append(f'{indent}result("raw:final", final_raw)') + for q in range(num_data): + frame_x, _frame_z = data_frames[q] + body.append(f"{indent}final_{q} = final_raw[{q}] != {frame_x}") + body.append(f'{indent}result("final", array({", ".join(f"final_{q}" for q in range(num_data))}))') + else: + body.append(f"{indent}final = measure_{basis}_basis(surf)") + body.append(f'{indent}result("final", final)') + + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis gate-local twirled memory experiment.', + "", + f" num_rounds must equal {num_rounds} -- the body was unrolled at", + " source-generation time. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {num_rounds}:", + f' msg = f"this generated module was unrolled for num_rounds={num_rounds}, got {{num_rounds!r}}"', + " raise ValueError(msg)", + "", + " @guppy", + f" def memory_{basis}() -> None:", + *body, + "", + f" return memory_{basis}", + "", + "", + ] + + def _validate_surface_memory_distance(d: int) -> None: """Enforce the surface-memory Guppy entry-point distance contract. @@ -411,23 +2267,84 @@ def _validate_surface_memory_distance(d: int) -> None: raise ValueError(msg) -def _guppy_module_cache_key(patch: "SurfacePatch", effective_budget: int) -> str: - """Filesystem-safe cache key spanning full patch identity + budget. +def _guppy_module_cache_key( + patch: "SurfacePatch", + effective_budget: int, + *, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, +) -> str: + """Filesystem-safe cache key spanning full patch identity + budget + twirl. Mirrors the topology identity used by the native cache (``decode._surface_patch_cache_key``): dx, dz, orientation, and the rotated flag. Keying on distance/dx-dz alone would collide a rotated and a non-rotated patch of the same shape onto one generated module. + + Memory factories close over ``num_rounds`` as a Guppy comptime value, so + the cache key includes it whenever a memory program is requested. This + keeps Guppy's module-qualified function names round-specific instead of + reusing a stale compiled body from an earlier factory call. Twirled source + and plain SZZ source also Python-time unroll the round body, so they require + this keying for source identity as well. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" - return f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) + if interaction_basis != "szz" and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE: + msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" + raise ValueError(msg) + interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" + check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" + frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" + runtime_barrier_part = ( + "" if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE else f"_szzrb-{szz_runtime_barrier_policy}" + ) + trace_metadata_part = "" if trace_metadata else "_trace-metadata-off" + base = ( + f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" + f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}" + f"{runtime_barrier_part}{trace_metadata_part}" + ) + if twirl is None: + if num_rounds is not None: + return f"{base}_r{int(num_rounds)}" + return base + if rng is None or num_rounds is None: + msg = "twirled Guppy module cache keys require both rng and num_rounds" + raise ValueError(msg) + twirl_part = ( + f"t-{twirl.scheme}-{twirl.site_schedule}-{twirl.result_encoding}" + f"-frame-{twirl.frame_output}" + f"-p{_twirl_activation_threshold(twirl)}" + f"-s{int(rng.seed)}-r{int(num_rounds)}" + ) + return f"{base}_{twirl_part}" def _load_guppy_module( patch: "SurfacePatch", *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -435,27 +2352,68 @@ def _load_guppy_module( rotated) and the **effective** budget (after clamping via ``normalize_ancilla_budget``), so ``ancilla_budget=None`` and ``ancilla_budget >= total_ancilla`` resolve to the same cache entry - while distinct patch geometries never collide. + while distinct patch geometries never collide. Twirled source also + keys on twirl fields, frame-output mode, activation-probability threshold, + RNG seed, round count, and SZZ runtime-barrier policy. Args: patch: SurfacePatch with geometry ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Pauli-twirl-site declaration (structural) + rng: Runtime mask RNG seed (must be supplied with ``twirl``) + num_rounds: Syndrome-round count for unrolled twirled source. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Module dictionary with generated functions """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = _guppy_module_cache_key(patch, effective_budget) + cache_key = _guppy_module_cache_key( + patch, + effective_budget, + twirl=twirl, + rng=rng, + num_rounds=num_rounds, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id if check_plan is not None else None, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, + ) if cache_key in _state.module_cache: return _state.module_cache[cache_key] - # Generate source for this (patch, effective_budget) combination. - source = generate_guppy_source(patch, ancilla_budget=ancilla_budget) + source = generate_guppy_source( + patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + rng=rng, + num_rounds=num_rounds, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, + ) # Write to temp file (required for Guppy introspection). temp_dir = _get_temp_dir() @@ -483,6 +2441,13 @@ def generate_memory_experiment( basis: str, *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> object: """Generate a memory experiment for a patch. @@ -491,11 +2456,38 @@ def generate_memory_experiment( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Pauli-twirl-site declaration; must be supplied with ``rng``. + rng: Runtime mask RNG seed; must be supplied with ``twirl``. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Guppy function for the experiment """ - module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + module = _load_guppy_module( + patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + rng=rng, + num_rounds=num_rounds, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id if check_plan is not None else None, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, + ) if basis.upper() == "Z": factory = module["make_memory_z"] @@ -513,6 +2505,10 @@ def get_num_qubits( *, patch: "SurfacePatch | None" = None, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -530,12 +2526,19 @@ def get_num_qubits( ``num_data + min(ancilla_budget, total_ancilla)`` slots are live at once. Clamping matches ``normalize_ancilla_budget``, so the unconstrained-via-``None`` and unconstrained-via-large-int cases collapse. + Twirled Guppy programs allocate one additional side-band entropy qubit at + a time for per-shot mask seeding. Returns: Total qubits the traced program will simultaneously use. """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis if (d is None) == (patch is None): msg = "get_num_qubits requires exactly one of d=... or patch=..." raise ValueError(msg) @@ -549,16 +2552,51 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 - return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) + if clifford_frame_policy is not None: + if patch is None: + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=d) + from pecos.qec.surface.circuit_builder import _resolve_szz_clifford_frame_for_builder + + _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) + + if interaction_basis == "szz" and twirl is not None: + msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" + raise ValueError(msg) + + twirl_entropy_qubits = 1 if twirl is not None else 0 + return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) + twirl_entropy_qubits -def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> str: +def generate_surface_code_module( + d: int, + *, + ancilla_budget: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, +) -> str: """Generate source code for a distance-d surface code module. Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas; forwarded to ``generate_guppy_source``. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Returns: Python/Guppy source code as a string @@ -568,10 +2606,62 @@ def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) - from pecos.qec.surface import SurfacePatch patch = SurfacePatch.create(distance=d) - return generate_guppy_source(patch, ancilla_budget=ancilla_budget) + return generate_guppy_source( + patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, + ) + + +def _round_scoped_surface_memory_factory( + patch: "SurfacePatch", + basis: str, + *, + ancilla_budget: int | None, + interaction_basis: str | None, + check_plan: str | None, + clifford_frame_policy: str | None, + szz_runtime_barriers: bool | str, +) -> Callable[[int], object]: + """Memory factory that scopes each call to a round-specific Guppy module. + + Backs :func:`get_surface_code_module`: each ``factory(n)`` re-enters + :func:`_surface_code_module_for_patch` with the concrete ``num_rounds`` so it + lands in a distinct ``pecos._generated.patch_..._r{n}`` module. Without this, + a caller building memory experiments at more than one round count on one + round-agnostic module would collide on a single guppy module-qualified name. + """ + + def factory(num_rounds: int) -> object: + scoped = _surface_code_module_for_patch( + patch, + ancilla_budget=ancilla_budget, + num_rounds=int(num_rounds), + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + ) + return scoped[f"make_memory_{basis}"](num_rounds) + + return factory -def _surface_code_module_for_patch(patch: "SurfacePatch", *, ancilla_budget: int | None = None) -> dict: +def _surface_code_module_for_patch( + patch: "SurfacePatch", + *, + ancilla_budget: int | None = None, + num_rounds: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, +) -> dict: """Load + cache a surface-code module for an arbitrary patch. Cache key spans full patch identity (dx, dz, orientation, rotated) plus @@ -579,35 +2669,126 @@ def _surface_code_module_for_patch(patch: "SurfacePatch", *, ancilla_budget: int unconstrained-via-``None`` / unconstrained-via-large-int cases share one entry. Module metadata is derived from the patch geometry (faithful for asymmetric / non-rotated patches), not from a scalar distance. + + ``num_rounds`` is threaded into both the cache key and the generated module + identity so that callers building memory experiments at different round + counts in one process get distinct Guppy modules (see + :func:`_guppy_module_cache_key`). When ``num_rounds`` is ``None`` the + returned ``make_memory_*`` factories are replaced with round-scoping + wrappers that re-enter this function with the concrete count, so the + round-agnostic getter path stays isolated across round counts too. """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + # Preserve the caller's original interaction-basis selector for the + # round-scoped factory wrappers below (the local is reassigned to the + # resolved basis just after). + original_interaction_basis = interaction_basis + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = (patch.dx, patch.dz, geom.orientation.name, geom.rotated, effective_budget) + cache_key = ( + patch.dx, + patch.dz, + geom.orientation.name, + geom.rotated, + effective_budget, + interaction_basis, + resolved_plan.plan_id, + None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), + szz_runtime_barrier_policy, + trace_metadata, + None if num_rounds is None else int(num_rounds), + ) if cache_key in _state.distance_module_cache: return _state.distance_module_cache[cache_key] - module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) + module = _load_guppy_module( + patch, + ancilla_budget=ancilla_budget, + num_rounds=num_rounds, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barrier_policy, + trace_metadata=trace_metadata, + ) # Metadata derived from the actual patch geometry. module["distance"] = patch.distance module["num_data"] = geom.num_data module["num_stab"] = total_ancilla module["ancilla_budget"] = effective_budget + module["interaction_basis"] = interaction_basis + module["check_plan"] = resolved_plan.plan_id + module["clifford_frame_policy"] = clifford_frame_policy + module["szz_runtime_barriers"] = szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + module["szz_runtime_barrier_policy"] = szz_runtime_barrier_policy + module["trace_metadata"] = trace_metadata + module["resolved_check_plan"] = resolved_plan.resolved_metadata + module["resolved_check_plan_hash"] = resolved_plan.resolved_hash + + if num_rounds is None: + # Round-agnostic getter path: hand back memory factories that scope each + # call to a round-specific module. Calling e.g. make_memory_z(2) then + # make_memory_z(6) on a single round-agnostic module would define both + # factory-local @guppy bodies under the same module-qualified name, so + # guppylang would reuse the first compiled body (see + # _guppy_module_cache_key). Copy first so the shared _load_guppy_module + # namespace keeps its real factories for the round-scoped re-entry. + module = dict(module) + for basis in ("z", "x"): + key = f"make_memory_{basis}" + if key in module: + module[key] = _round_scoped_surface_memory_factory( + patch, + basis, + ancilla_budget=ancilla_budget, + interaction_basis=original_interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + ) _state.distance_module_cache[cache_key] = module return module -def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dict: +def get_surface_code_module( + d: int, + *, + ancilla_budget: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, +) -> dict: """Get a loaded surface code module for distance d. + The returned ``make_memory_z``/``make_memory_x`` factories scope each call to + a round-specific module: ``factory(n)`` always produces (and caches) the + experiment in a distinct ``pecos._generated.patch_..._r{n}`` module. The + round count is therefore taken solely from the factory argument -- there is + no separate module-level round count to keep in sync -- so building + experiments at several round counts in one process stays isolated (guppylang + caches compiled functions by module-qualified name). + Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Dictionary with module contents and metadata @@ -616,7 +2797,14 @@ def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dic _validate_surface_memory_distance(d) patch = SurfacePatch.create(distance=d) - return _surface_code_module_for_patch(patch, ancilla_budget=ancilla_budget) + return _surface_code_module_for_patch( + patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + ) def make_surface_code( @@ -625,6 +2813,11 @@ def make_surface_code( basis: str, *, ancilla_budget: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> object: """Create a surface code memory experiment. @@ -637,6 +2830,16 @@ def make_surface_code( a finite budget emits a stabilizer-batched program that matches the abstract circuit's ``batched_stabilizers(patch, effective_budget)`` schedule. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Compiled Guppy program @@ -645,8 +2848,18 @@ def make_surface_code( msg = f"basis must be 'Z' or 'X', got {basis!r}" raise ValueError(msg) - module = get_surface_code_module(distance, ancilla_budget=ancilla_budget) - - factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] + from pecos.qec.surface import SurfacePatch - return factory(num_rounds) + _validate_surface_memory_distance(distance) + patch = SurfacePatch.create(distance=distance) + return generate_memory_experiment( + patch, + num_rounds, + basis, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, + ) diff --git a/python/quantum-pecos/src/pecos/guppy/transversal.py b/python/quantum-pecos/src/pecos/guppy/transversal.py index 27578603e..7b1918bdc 100644 --- a/python/quantum-pecos/src/pecos/guppy/transversal.py +++ b/python/quantum-pecos/src/pecos/guppy/transversal.py @@ -744,9 +744,19 @@ def _add_color_transversal_factory_functions( ) -def _load_css_transversal_module(code_type: CSSCodeType, d: int) -> dict: - """Load a transversal module for the given code type and distance.""" - cache_key = f"{code_type.value}_d{d}" +def _load_css_transversal_module(code_type: CSSCodeType, d: int, *, num_rounds: int | None = None) -> dict: + """Load a transversal module for the given code type and distance. + + The generated experiment factories (``make_transversal_cnot`` etc.) close + over ``num_rounds`` as a Guppy ``comptime`` value baked into a factory-local + ``@guppy`` function whose qualified name is constant across round counts. + guppylang keys compiled functions by ``__module__ + __qualname__`` in a + process-global store, so the module identity must encode ``num_rounds`` -- + otherwise a second round count reuses the first's compiled body (the same + cross-``num_rounds`` leak fixed for the surface/color memory modules). + """ + round_suffix = "" if num_rounds is None else f"_r{int(num_rounds)}" + cache_key = f"{code_type.value}_d{d}{round_suffix}" if cache_key in _state.css_transversal_cache: return _state.css_transversal_cache[cache_key] @@ -809,7 +819,7 @@ def make_css_transversal_cnot( msg = f"Distance must be odd >= 3, got {distance}" raise ValueError(msg) - module = _load_css_transversal_module(code_type, distance) + module = _load_css_transversal_module(code_type, distance, num_rounds=num_rounds) return module["make_transversal_cnot"](num_rounds) @@ -837,7 +847,7 @@ def make_css_transversal_cnot_with_x( msg = f"Distance must be odd >= 3, got {distance}" raise ValueError(msg) - module = _load_css_transversal_module(code_type, distance) + module = _load_css_transversal_module(code_type, distance, num_rounds=num_rounds) return module["make_transversal_cnot_with_x"](num_rounds) diff --git a/python/quantum-pecos/src/pecos/guppy/variant.py b/python/quantum-pecos/src/pecos/guppy/variant.py new file mode 100644 index 000000000..60eeff391 --- /dev/null +++ b/python/quantum-pecos/src/pecos/guppy/variant.py @@ -0,0 +1,81 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Variant-scoped Guppy program factories. + +guppylang registers each ``@guppy`` function by its module-qualified name, and +the downstream Selene build cache keys compiled executables by the HUGR +entry-point name (that same function name). A factory that builds a ``@guppy`` +program closing over compile-time parameters therefore hands every +parameterization the *same* name:: + + def make_prog(num_rounds): + @guppy + def prog() -> None: # __qualname__ == "make_prog..prog" + for _ in range(comptime(num_rounds)): + ... + return prog + +Building two parameterizations in one process then collides: the second reuses +the first's cached compilation / Selene executable and silently runs the wrong +program. :func:`variant_scoped` gives each parameterization a distinct name so its +identity -- and thus its HUGR entry point and Selene build -- is unique. +""" + +from collections.abc import Callable +from typing import TypeVar + +F = TypeVar("F", bound=Callable) + +__all__ = ["variant_scoped"] + + +def _name_fragment(value: object) -> str: + """Render a variant value as an identifier-safe name fragment.""" + return "".join(ch if ch.isalnum() else "_" for ch in str(value)) + + +def variant_scoped(func: F, *variant: object) -> F: + """Rename ``func`` with a ``variant`` suffix for a parameterization-unique ``guppy()`` program. + + Define the program as a plain (undecorated) nested function and wrap it with + ``guppy()`` at the call site, passing the values that distinguish this + parameterization:: + + from guppylang import guppy + from pecos.guppy import variant_scoped + + def make_prog(num_rounds): + def prog() -> None: + for _ in range(comptime(num_rounds)): + ... + return guppy(variant_scoped(prog, num_rounds)) + + ``variant_scoped`` only renames ``func`` (setting ``__name__``/``__qualname__``) + and returns it; **you** apply ``guppy()`` to the result. This is required + because guppylang resolves the program's names (gates, ``result``, ...) against + the *caller's* module, so ``guppy()`` must be invoked where those names are in + scope -- a helper that called ``guppy()`` itself would fail to resolve them. + + The variant suffix gives each parameterization a distinct module-qualified + name (so guppylang does not reuse a stale compiled body) and a distinct HUGR + entry-point name (so the Selene build cache does not reuse a stale + executable). Without it, building two parameterizations in one process makes + the second silently execute the first's program. + + Args: + func: The undecorated program function to rename in place. + variant: One or more values identifying this parameterization (e.g. the + round count, distance). At least one is required; each is stringified + and sanitized into the name suffix. + + Returns: + ``func`` (renamed), ready to pass to ``guppy()`` in the caller's module. + """ + if not variant: + msg = "variant_scoped requires at least one distinguishing value" + raise ValueError(msg) + suffix = "_" + "_".join(_name_fragment(v) for v in variant) + func.__name__ = f"{func.__name__}{suffix}" + func.__qualname__ = f"{func.__qualname__}{suffix}" + return func diff --git a/python/quantum-pecos/src/pecos/programs/__init__.py b/python/quantum-pecos/src/pecos/programs/__init__.py index 370edf342..9ff8102e2 100644 --- a/python/quantum-pecos/src/pecos/programs/__init__.py +++ b/python/quantum-pecos/src/pecos/programs/__init__.py @@ -130,10 +130,13 @@ def _to_program(self) -> "CompiledHugr": """Convert to the underlying Rust program type.""" if self._program is None: hugr_package = self._func.compile() - # Use JSON format (via to_str) instead of binary format (to_bytes) - # The JSON format is more reliably parsed and supports all HUGR features - # including CFG loops (while statements) - hugr_bytes = hugr_package.to_str().encode("utf-8") + # Use the BINARY HUGR envelope (Model format). The Selene/QIS engine's + # HUGR reader does not accept hugr-py 0.16's S-expression *text* envelope + # (`to_str`) -- loading it fails with "Failed to read HUGR" -- whereas the + # binary Model form round-trips cleanly, including CFG loops (while + # statements). (The `Hugr.from_bytes` sim loader is more permissive and + # accepts either, but the QIS engine path used for DEM tracing is not.) + hugr_bytes = hugr_package.to_bytes() self._program = pecos_rslib.Hugr.from_bytes(hugr_bytes) return self._program diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 7aac871c1..fb1274d14 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -38,6 +38,7 @@ FaultLocation, InfluenceBuilder, ParsedDem, + PauliFrameLookup, assert_dems_equivalent, compare_dems_exact, compare_dems_statistical, @@ -72,10 +73,10 @@ generate_488_layout, ) -# DetectorErrorModel is re-exported from pecos.qec.dem: a thin Python subclass -# of the Rust class that adds the from_guppy convenience constructor (the -# Guppy/Selene trace pipeline is Python-only, so it cannot live in the Rust -# extension without a dependency cycle). +# DetectorErrorModel is re-exported from pecos.qec.dem: the Rust class with a +# Python from_guppy convenience constructor attached. The Guppy/Selene trace +# pipeline is Python-only, so it cannot live in the Rust extension without a +# dependency cycle. from pecos.qec.dem import DetectorErrorModel from pecos.qec.generic import ( CheckSchedule, @@ -123,6 +124,7 @@ "EquivalenceResult", "FaultLocation", "InfluenceBuilder", + "PauliFrameLookup", "ParsedDem", "assert_dems_equivalent", "compare_dems_exact", diff --git a/python/quantum-pecos/src/pecos/qec/analysis.py b/python/quantum-pecos/src/pecos/qec/analysis.py index 2f22f1f8e..fb91ac8ce 100644 --- a/python/quantum-pecos/src/pecos/qec/analysis.py +++ b/python/quantum-pecos/src/pecos/qec/analysis.py @@ -580,16 +580,31 @@ def empirical_correlation_table( """ from pecos_rslib_exp import ( meas_sampling, + monte_carlo, sim_neo, stabilizer, statevec, ) if backend == "meas_sampling": - results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).shots(shots).seed(seed).run() + results = ( + sim_neo(tick_circuit) + .quantum(meas_sampling()) + .noise(noise_builder) + .sampling(monte_carlo(shots)) + .seed(seed) + .run() + ) elif backend in ("stabilizer", "statevec"): backend_obj = stabilizer() if backend == "stabilizer" else statevec() - results = sim_neo(tick_circuit).quantum(backend_obj).noise(noise_builder).shots(shots).seed(seed).run() + results = ( + sim_neo(tick_circuit) + .quantum(backend_obj) + .noise(noise_builder) + .sampling(monte_carlo(shots)) + .seed(seed) + .run() + ) else: supported = "'stabilizer', 'statevec', 'meas_sampling'" msg = f"Unknown backend {backend!r}. Supported: {supported}." @@ -699,6 +714,7 @@ def fit_dem_from_simulation( ) from pecos_rslib_exp import ( meas_sampling, + monte_carlo, sim_neo, stabilizer, statevec, @@ -736,10 +752,24 @@ def fit_dem_from_simulation( num_dets = len(det_json) if backend == "meas_sampling": - results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).shots(shots).seed(seed).run() + results = ( + sim_neo(tick_circuit) + .quantum(meas_sampling()) + .noise(noise_builder) + .sampling(monte_carlo(shots)) + .seed(seed) + .run() + ) elif backend in ("stabilizer", "statevec"): backend_obj = stabilizer() if backend == "stabilizer" else statevec() - results = sim_neo(tick_circuit).quantum(backend_obj).noise(noise_builder).shots(shots).seed(seed).run() + results = ( + sim_neo(tick_circuit) + .quantum(backend_obj) + .noise(noise_builder) + .sampling(monte_carlo(shots)) + .seed(seed) + .run() + ) else: supported = "'stabilizer', 'statevec', 'meas_sampling'" msg = f"Unknown backend {backend!r}. Supported: {supported}." diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 1adf274c2..e2b0e8a9d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -6,8 +6,9 @@ ``pecos.qec.surface.decode``). To keep the convenient ``DetectorErrorModel.from_guppy(...)`` call site without making the low-level Rust extension import the high-level Python package (a dependency cycle), this -module defines a thin Python subclass that adds :meth:`from_guppy` and is -re-exported as the public ``pecos.qec.DetectorErrorModel``. +module attaches a Python :meth:`from_guppy` classmethod to the Rust-backed +``pecos_rslib.qec.DetectorErrorModel`` and re-exports that class as the public +``pecos.qec.DetectorErrorModel``. This wrapper is intentionally thin: it traces the Guppy program into a ``TickCircuit``, optionally compiles the program to a HUGR (only when @@ -35,27 +36,70 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel - -class DetectorErrorModel(_RustDetectorErrorModel): - """Detector error model with a Guppy/QIS-trace convenience constructor. - - Identical to :class:`pecos_rslib.qec.DetectorErrorModel` except for the - added :meth:`from_guppy` classmethod. - - Identity caveat: the inherited Rust factory classmethods - (``from_circuit``, ``from_pecos_metadata_json``, and ``from_guppy``, which - delegates to ``from_circuit``) construct and return the *Rust base* class - ``pecos_rslib.qec.DetectorErrorModel`` -- they do not return instances of - this Python subclass. Consequently ``isinstance(obj, DetectorErrorModel)`` - is ``False`` for objects produced by those constructors even though every - method works identically. Do not use ``isinstance`` against this public - subclass to recognize DEMs; check the Rust base type instead. (No PECOS - code relies on such an ``isinstance``; this is a public-API caveat only.) - """ +P1Weights = Mapping[str, float] +P2Weights = Mapping[str, float] + + +def _from_circuit_with_noise( + tc: Any, + *, + p1: float, + p1_weights: P1Weights | None, + p2: float, + p2_weights: P2Weights | None, + p2_replacement_approximation: str | None, + p_meas: float, + p_prep: float, + p_idle: float | None, + t1: float | None, + t2: float | None, + p_idle_linear_rate: float | None, + p_idle_quadratic_rate: float | None, + p_idle_x_linear_rate: float | None, + p_idle_y_linear_rate: float | None, + p_idle_z_linear_rate: float | None, + p_idle_x_quadratic_rate: float | None, + p_idle_y_quadratic_rate: float | None, + p_idle_z_quadratic_rate: float | None, + p_idle_quadratic_sine_rate: float | None, + p_idle_x_quadratic_sine_rate: float | None, + p_idle_y_quadratic_sine_rate: float | None, + p_idle_z_quadratic_sine_rate: float | None, +) -> _RustDetectorErrorModel: + return _RustDetectorErrorModel.from_circuit( + tc, + p1=p1, + p1_weights=p1_weights, + p2=p2, + p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, + p_meas=p_meas, + p_prep=p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + + +class _DetectorErrorModelMixin: + """Namespace for the Python Guppy/QIS-trace convenience constructor.""" __slots__ = () @@ -69,10 +113,31 @@ def from_guppy( observables_json: str = "[]", num_measurements: int | None = None, p1: float = 0.001, + p1_weights: P1Weights | None = None, p2: float = 0.01, + p2_weights: P2Weights | None = None, + p2_replacement_approximation: str | None = None, p_meas: float = 0.001, p_prep: float = 0.001, + p_idle: float | None = None, + t1: float | None = None, + t2: float | None = None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, + p_idle_x_linear_rate: float | None = None, + p_idle_y_linear_rate: float | None = None, + p_idle_z_linear_rate: float | None = None, + p_idle_x_quadratic_rate: float | None = None, + p_idle_y_quadratic_rate: float | None = None, + p_idle_z_quadratic_rate: float | None = None, + p_idle_quadratic_sine_rate: float | None = None, + p_idle_x_quadratic_sine_rate: float | None = None, + p_idle_y_quadratic_sine_rate: float | None = None, + p_idle_z_quadratic_sine_rate: float | None = None, + runtime: object | None = None, seed: int = 0, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> _RustDetectorErrorModel: """Build a circuit-level DEM from a Guppy program by tracing it. @@ -138,11 +203,55 @@ def from_guppy( num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced circuit; if given, it must match the traced count. - p1: Single-qubit gate depolarizing rate. + p1: Single-qubit gate Pauli error rate. + p1_weights: Optional relative probabilities over single-qubit + Pauli error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must + sum to 1.0; ``p1`` remains the total single-qubit error rate. p2: Two-qubit gate depolarizing rate. + p2_weights: Optional relative probabilities over two-qubit Pauli + error labels. Plain labels such as ``"XX"`` are post-gate + Pauli branches; labels prefixed by ``"*"`` such as ``"*XX"`` + are replacement branches that omit the ideal two-qubit gate + before applying the Pauli. Values must sum to 1.0; ``p2`` + remains the total two-qubit error rate. + p2_replacement_approximation: Approximation used for starred + replacement labels. ``"pauli_twirl_omitted_gate"`` convolves + with the omitted two-qubit gate's Pauli twirl; + ``"branch_impact"`` evaluates starred entries as replacement + branch impacts; ``"exact_branch_replay"`` is reserved for a + future circuit-aware exact replay provider and currently fails + loudly for starred entries; ``"ignore_gate_removal"`` treats starred + entries like plain post-gate Pauli entries. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. + p_idle: Optional uniform depolarizing idle-noise rate per idle duration. + t1: Optional T1 relaxation time for explicit idle gates. + t2: Optional T2 dephasing time for explicit idle gates. + p_idle_linear_rate: Optional legacy alias for stochastic Z-memory rate + linear in idle duration. + p_idle_quadratic_rate: Optional legacy alias for stochastic Z-memory rate + quadratic in idle duration. + p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. + p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. + p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. + p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. + p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. + p_idle_quadratic_sine_rate: Optional legacy alias for stochastic Z-memory + rate with probability ``sin(rate * duration)^2``. + p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. + p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. + p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. + runtime: Optional Selene runtime selector/plugin. ``None`` selects + the default Selene runtime. Runtime plugin objects are passed + through to ``pecos.selene_engine(runtime)``. seed: Seed for the ideal trace run. + require_hosted_operation_order: If true, validate generic + hosted-operation metadata after trace replay. A gate with + ``local_role`` metadata must bind to a later same-``host_id`` + host gate sharing a qubit. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A ``DetectorErrorModel`` built from the traced circuit. @@ -176,6 +285,7 @@ def from_guppy( scalar ``result(tag, measure(q))`` in straight-line programs; the runtime-loop case (per-occurrence binding) remains deferred. """ + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import trace_guppy_into_tick_circuit # Tag-referenced detectors require the compiled HUGR (to recover the @@ -203,14 +313,20 @@ def from_guppy( ) raise ValueError(msg) from exc - tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed) + tc = trace_guppy_into_tick_circuit( + guppy, + num_qubits, + seed=seed, + runtime=runtime, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + ) # Compilation passes required for traced QIS circuits before fault - # analysis: normalize parameterized Clifford rotations to named gates - # and stamp stable MeasIds onto measurement gates. After this every - # MZ carries the stable id the Rust builder resolves meas_ids against. - tc.lower_clifford_rotations() - tc.assign_missing_meas_ids() + # analysis: normalize parameterized Clifford rotations to named gates, + # stamp stable MeasIds onto measurement gates, and fail loudly if raw + # traced-QIS rotations survived normalization. + normalize_traced_qis_tick_circuit(tc, context="DetectorErrorModel.from_guppy") # Resolve `result_tags` -> record offsets via Rust (sound HUGR # extraction + runtime-loop guard via static-vs-traced measurement @@ -235,12 +351,30 @@ def from_guppy( if num_measurements is not None: tc.set_meta("num_measurements", str(num_measurements)) - return _RustDetectorErrorModel.from_circuit( + return _from_circuit_with_noise( tc, p1=p1, + p1_weights=p1_weights, p2=p2, + p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) @@ -251,3 +385,7 @@ def _result_tags_present(detectors_json: str, observables_json: str) -> bool: extraction, loop-guard, resolution, and validation are all done in Rust. """ return '"result_tags"' in (detectors_json or "") or '"result_tags"' in (observables_json or "") + + +DetectorErrorModel = _RustDetectorErrorModel +DetectorErrorModel.from_guppy = classmethod(_DetectorErrorModelMixin.__dict__["from_guppy"].__func__) diff --git a/python/quantum-pecos/src/pecos/qec/reliable_observables.py b/python/quantum-pecos/src/pecos/qec/reliable_observables.py new file mode 100644 index 000000000..7b3554ae1 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/reliable_observables.py @@ -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. + +"""PROTOTYPE: reliable logical-observable combinations from reset structure. + +Port of lomatching's ``get_reliable_observables`` (Serra-Peralta et al., +arXiv:2505.13599, "logical observable matching"). A logical observable is +*fragile* when its back-propagated observing region anticommutes with a reset +stabilizer -- decoding it directly is unreliable (a weight-2 space-time +stabilizer flips its decoded outcome without a logical fault). The paper's fix +is to decode only an **independent generating set of reliable observables** and +infer fragile ones as products. + +This module computes that reliable set: build the anticommutation matrix +``A[reset, obs]`` (1 iff reset ``reset`` and observable ``obs`` anticommute) and +take its right null space over GF(2). Each null vector is a combination of raw +observables that commutes with *every* reset -- i.e. a reliable observable. + +Status: prototype / proof-of-concept -- NOT part of the supported ``pecos.qec`` +API. It is deliberately *not* re-exported by ``pecos.qec.__init__`` (importing +``pecos.qec`` does not pull it in). QUARANTINE: this module imports ``stim`` at +module load and uses ``stim.Circuit.detecting_regions`` at runtime, which +violates the project rule that externals (stim, numpy, ...) are dev/test oracles, +never on a runtime path. Do NOT import this on any decode path. A production +version must compute observing regions natively (pecos-eeg / EEG-Heisenberg) and +the GF(2) null space via pecos-num. It is also currently a no-op on the circuits +PECOS emits today (every observable is already reliable). See +``pecos-docs/design/lomatching-paper-additional-learnings.md``. +""" + +from __future__ import annotations + +import numpy as np + +try: + import stim +except ImportError as exc: # pragma: no cover - stim is an optional dep + msg = "reliable_observables requires the `stim` package" + raise ImportError(msg) from exc + +# Stim reset instruction names (Pauli basis is the last character; default Z). +_RESET_INSTRS = frozenset(["R", "RX", "RY", "RZ", "MR", "MRX", "MRY", "MRZ"]) + +# A Pauli region is {tick_index: stim.PauliString}. +PauliRegion = dict[int, "stim.PauliString"] + + +def reliable_observables(circuit: stim.Circuit) -> list[set[int]]: + """Return a complete basis of reliable observable combinations. + + Args: + circuit: A ``stim.Circuit`` whose observables are defined via + ``OBSERVABLE_INCLUDE``. Qubits must be explicitly reset, and a reset + must be the only operation on its qubit within its ``TICK`` (the + lomatching precondition). + + Returns: + One ``set[int]`` per basis element of the reliable space; each set is the + raw-observable indices whose XOR is a reliable observable. An empty list + means no nontrivial reliable combination exists. + """ + if not isinstance(circuit, stim.Circuit): + msg = f"`circuit` must be a stim.Circuit, got {type(circuit)}" + raise TypeError(msg) + + flat = circuit.flattened() + resets = _reset_pauli_regions(flat) + num_obs = circuit.num_observables + obs_regions = {o: _observing_region(flat, o) for o in range(num_obs)} + + # A[reset, obs] = 1 iff the reset and the observable's region anticommute. + a = np.zeros((len(resets), num_obs), dtype=np.uint8) + for obs_id, region in obs_regions.items(): + for reset_id, reset_region in resets.items(): + if _anticommute(reset_region, region): + a[reset_id, obs_id] = 1 + + return [set(np.nonzero(vec)[0].tolist()) for vec in _gf2_right_null_space(a)] + + +def is_reliable(circuit: stim.Circuit, observable: set[int] | int) -> bool: + """Whether a single observable (or XOR of observables) is reliable. + + A combination is reliable iff its region commutes with every reset. + """ + obs = {observable} if isinstance(observable, int) else set(observable) + flat = circuit.flattened() + resets = _reset_pauli_regions(flat) + # Combine the regions of the chosen observables by tick-wise Pauli product. + combined: PauliRegion = {} + for o in obs: + for tick, ps in _observing_region(flat, o).items(): + combined[tick] = combined[tick] * ps if tick in combined else ps + return all(not _anticommute(r, combined) for r in resets.values()) + + +# --------------------------------------------------------------------------- # +# Internals (faithful to lomatching's util.py) +# --------------------------------------------------------------------------- # + + +def _reset_pauli_regions(flat: stim.Circuit) -> dict[int, PauliRegion]: + """Per-reset single-tick Pauli region: the reset's Pauli on its qubit. + + ``flat`` must already be flattened (``circuit.flattened()``); callers flatten + once and share it across observables to avoid repeated flattening. + """ + n = flat.num_qubits + resets: dict[int, PauliRegion] = {} + reset_idx = 0 + tick = 0 + for instr in flat: + if instr.name == "TICK": + tick += 1 + continue + if instr.name not in _RESET_INSTRS: + continue + pauli = "Z" + if instr.name.endswith("X"): + pauli = "X" + elif instr.name.endswith("Y"): + pauli = "Y" + for target in instr.targets_copy(): + ps = stim.PauliString(n) + ps[target.value] = pauli + resets[reset_idx] = {tick: ps} + reset_idx += 1 + return resets + + +def _observing_region(flat: stim.Circuit, observable: int) -> PauliRegion: + """Back-propagated observing region of one observable, via stim. + + Rewrites the (already-flattened) circuit so only `observable` survives, + renamed to L0, then uses `stim.Circuit.detecting_regions` to get its + {tick: PauliString} region. + """ + new_circuit = stim.Circuit() + for instr in flat: + if instr.name != "OBSERVABLE_INCLUDE": + new_circuit.append(instr) + continue + if instr.gate_args_copy()[0] != observable: + continue + new_circuit.append( + stim.CircuitInstruction( + name="OBSERVABLE_INCLUDE", + gate_args=[0], + targets=instr.targets_copy(), + ), + ) + target = stim.DemTarget("L0") + regions = new_circuit.detecting_regions( + targets=[target], + ignore_anticommutation_errors=True, + ) + return regions.get(target, {}) + + +def _anticommute(region_a: PauliRegion, region_b: PauliRegion) -> bool: + """True iff two Pauli regions anticommute (odd number of anticommuting ticks).""" + anti = 0 + for tick in set(region_a).intersection(region_b): + if not region_a[tick].commutes(region_b[tick]): + anti += 1 + return anti % 2 == 1 + + +def _gf2_right_null_space(a: np.ndarray) -> list[np.ndarray]: + """Basis of {x : a @ x == 0 (mod 2)} over GF(2), via row reduction. + + Returns a list of 0/1 vectors of length ``a.shape[1]``. + """ + a = (np.asarray(a, dtype=np.uint8) % 2).copy() + rows, cols = a.shape + pivot_col_of_row: list[int] = [] + pivot_cols: set[int] = set() + r = 0 + for c in range(cols): + # find a pivot in column c at or below row r + piv = next((i for i in range(r, rows) if a[i, c]), None) + if piv is None: + continue + a[[r, piv]] = a[[piv, r]] + for i in range(rows): + if i != r and a[i, c]: + a[i] ^= a[r] + pivot_col_of_row.append(c) + pivot_cols.add(c) + r += 1 + if r == rows: + break + + free_cols = [c for c in range(cols) if c not in pivot_cols] + basis: list[np.ndarray] = [] + for f in free_cols: + x = np.zeros(cols, dtype=np.uint8) + x[f] = 1 + # back-substitute: pivot row i fixes its pivot col from the free col + for i, pc in enumerate(pivot_col_of_row): + if a[i, f]: + x[pc] = 1 + basis.append(x) + return basis diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index 5aee25b2e..4aba263df 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -18,6 +18,18 @@ """ # Circuit generation from geometry (unified abstraction) +from pecos.qec.surface._clifford_deformation import ( + LocalCliffordFrame, + ResolvedPauliCheck, + ResolvedPauliLogical, + ResolvedSurfaceCliffordFrame, + SignedPauli, + global_surface_frame, + normalize_surface_frame_policy, + resolve_surface_clifford_frame, +) +from pecos.qec.surface._detection_events import extract_detection_events_and_observables +from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig from pecos.qec.surface.circuit_builder import ( DagCircuitRenderer, GuppyRenderer, @@ -54,12 +66,17 @@ SurfaceDecoder, build_memory_circuit, build_native_sampler, + build_native_sampler_from_dem, build_stim_circuit_from_patch, + decode_native_samples, + demask_pauli_frame_records, generate_circuit_level_dem, generate_dem_from_patch, generate_repetition_code_dem, generate_surface_code_dem, run_noisy_memory_experiment, + sample_pauli_activations_from_guppy, + sample_pauli_masks_from_guppy, surface_code_memory, syndromes_to_detection_events, ) @@ -107,6 +124,18 @@ ) __all__ = [ + # Twirling config (Pauli-frame randomization) + "GuppyRngMaskConfig", + "TwirlConfig", + # Clifford-deformed surface-code metadata + "LocalCliffordFrame", + "ResolvedPauliCheck", + "ResolvedPauliLogical", + "ResolvedSurfaceCliffordFrame", + "SignedPauli", + "global_surface_frame", + "normalize_surface_frame_policy", + "resolve_surface_clifford_frame", # Rotated lattice (most common, default) "compute_rotated_x_stabilizers", "compute_rotated_z_stabilizers", @@ -144,12 +173,18 @@ "SurfaceDecoder", "build_memory_circuit", "build_native_sampler", + "build_native_sampler_from_dem", "build_stim_circuit_from_patch", + "decode_native_samples", + "demask_pauli_frame_records", + "extract_detection_events_and_observables", "generate_circuit_level_dem", "generate_dem_from_patch", "generate_repetition_code_dem", "generate_surface_code_dem", "run_noisy_memory_experiment", + "sample_pauli_activations_from_guppy", + "sample_pauli_masks_from_guppy", "surface_code_memory", "syndromes_to_detection_events", # Visualization diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index 256b8a565..af61f2ca3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -23,12 +23,38 @@ from __future__ import annotations +from math import ceil from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Iterable + from pecos.qec.surface.geometry import SurfacePatch +DEFAULT_ANCILLA_SCHEDULE = "default" +BALANCED_DATA_ANCILLA_SCHEDULE = "balanced-data-v1" +SUPPORTED_ANCILLA_SCHEDULES = frozenset( + {DEFAULT_ANCILLA_SCHEDULE, BALANCED_DATA_ANCILLA_SCHEDULE}, +) + + +def normalize_ancilla_schedule(ancilla_schedule: str | None = None) -> str: + """Return the canonical named ancilla-reuse schedule. + + ``None`` is the historical default batching policy. Non-default policies + must be explicit in source-level check-plan metadata so DEM caches and + traced programs cannot accidentally collide with the legacy schedule. + """ + if ancilla_schedule is None: + return DEFAULT_ANCILLA_SCHEDULE + normalized = str(ancilla_schedule).lower().replace("_", "-") + if normalized not in SUPPORTED_ANCILLA_SCHEDULES: + msg = f"ancilla_schedule must be one of {sorted(SUPPORTED_ANCILLA_SCHEDULES)}, got {ancilla_schedule!r}" + raise ValueError(msg) + return normalized + + def normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> int: """Clamp an ancilla budget to the valid range for a patch. @@ -59,6 +85,8 @@ def normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> def batched_stabilizers( patch: SurfacePatch, ancilla_budget: int, + *, + ancilla_schedule: str | None = None, ) -> list[list[tuple[str, int]]]: """Partition stabilizers into ancilla-reuse batches. @@ -68,13 +96,21 @@ def batched_stabilizers( ``ancilla_budget`` stabilizers each; within each batch every stabilizer is measured concurrently using one ancilla qubit. - The stabilizer order is **load-bearing** production semantics shared by - the abstract circuit and the Guppy emitter: ascending stabilizer index, - X before Z on ties. Note the traced-vs-traced Selene parity tests cannot - catch a regression here -- both sides import this one helper, so a policy - change moves them together. The concrete batch-order and source-level - CX-emission pins (``tests/qec/surface/test_ancilla_batching.py``) are what - actually guard this order; preserve it. + The default stabilizer order is **load-bearing** production semantics + shared by the abstract circuit and the Guppy emitter: ascending stabilizer + index, X before Z on ties. Note the traced-vs-traced Selene parity tests + cannot catch a regression here -- both sides import this one helper, so a + policy change moves them together. The concrete batch-order and + source-level CX-emission pins + (``tests/qec/surface/test_ancilla_batching.py``) are what actually guard + this order; preserve it. + + ``ancilla_schedule="balanced-data-v1"`` is an explicit non-default policy + for constrained-ancilla programs. It greedily spreads stabilizer supports + across each batch so data qubits see check interactions more uniformly + through the batched sequence. This does not change result tags or detector + semantics; callers must still record the chosen schedule in check-plan + metadata so cached DEMs do not collide with the default schedule. ``ancilla_budget`` is validated through :func:`normalize_ancilla_budget` (rejects ``None``, ``bool``, @@ -83,12 +119,107 @@ def batched_stabilizers( public ``ancilla_budget`` API surface, not an opaque ``range()`` or silent-empty failure. """ + schedule = normalize_ancilla_schedule(ancilla_schedule) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) + stabilizers = _canonical_stabilizer_order(patch) + if schedule == BALANCED_DATA_ANCILLA_SCHEDULE: + return _balanced_data_batches(patch, stabilizers, effective_budget) + + return [stabilizers[start : start + effective_budget] for start in range(0, len(stabilizers), effective_budget)] + + +def _canonical_stabilizer_order(patch: SurfacePatch) -> list[tuple[str, int]]: + geom = patch.geometry stabilizers = [("X", stab.index) for stab in geom.x_stabilizers] stabilizers.extend(("Z", stab.index) for stab in geom.z_stabilizers) - stabilizers.sort(key=lambda stab: (stab[1], 0 if stab[0] == "X" else 1)) + stabilizers.sort(key=_canonical_stabilizer_sort_key) + return stabilizers - return [stabilizers[start : start + effective_budget] for start in range(0, len(stabilizers), effective_budget)] + +def _canonical_stabilizer_sort_key(stabilizer: tuple[str, int]) -> tuple[int, int]: + stab_type, stab_idx = stabilizer + return (stab_idx, 0 if stab_type == "X" else 1) + + +def _balanced_data_batches( + patch: SurfacePatch, + stabilizers: list[tuple[str, int]], + effective_budget: int, +) -> list[list[tuple[str, int]]]: + """Greedily spread data-qubit supports inside each constrained batch.""" + if effective_budget >= len(stabilizers): + return [stabilizers] + + by_stabilizer = _stabilizer_support_lookup(patch) + canonical_order = {stabilizer: index for index, stabilizer in enumerate(stabilizers)} + remaining = set(stabilizers) + batches: list[list[tuple[str, int]]] = [] + for batch_index, target_size in enumerate(_balanced_batch_sizes(len(stabilizers), effective_budget)): + batch: list[tuple[str, int]] = [] + touched_data: set[int] = set() + x_count = 0 + z_count = 0 + + while remaining and len(batch) < target_size: + score_state = (frozenset(touched_data), x_count, z_count, batch_index) + + def score( + stabilizer: tuple[str, int], + *, + state: tuple[frozenset[int], int, int, int] = score_state, + ) -> tuple[int, int, float, float, int]: + bound_touched_data, bound_x_count, bound_z_count, bound_batch_index = state + support, row, col = by_stabilizer[stabilizer] + overlap = sum(data_qubit in bound_touched_data for data_qubit in support) + next_x = bound_x_count + (1 if stabilizer[0] == "X" else 0) + next_z = bound_z_count + (1 if stabilizer[0] == "Z" else 0) + type_imbalance = abs(next_x - next_z) + # Alternate the spatial sweep direction between batches so the + # deterministic tie-break does not repeatedly privilege the + # same edge of the patch. + row_key = row if bound_batch_index % 2 == 0 else -row + col_key = col if bound_batch_index % 2 == 0 else -col + return (overlap, type_imbalance, row_key, col_key, canonical_order[stabilizer]) + + selected = min(remaining, key=score) + remaining.remove(selected) + batch.append(selected) + support, _row, _col = by_stabilizer[selected] + touched_data.update(support) + if selected[0] == "X": + x_count += 1 + else: + z_count += 1 + + batches.append(batch) + + return batches + + +def _balanced_batch_sizes(total: int, effective_budget: int) -> list[int]: + """Return near-equal batch sizes, each at most ``effective_budget``.""" + num_batches = ceil(total / effective_budget) + base_size, remainder = divmod(total, num_batches) + return [base_size + (1 if index < remainder else 0) for index in range(num_batches)] + + +def _stabilizer_support_lookup( + patch: SurfacePatch, +) -> dict[tuple[str, int], tuple[tuple[int, ...], float, float]]: + geom = patch.geometry + lookup: dict[tuple[str, int], tuple[tuple[int, ...], float, float]] = {} + + def add(stabilizers: Iterable[object], stab_type: str) -> None: + for stabilizer in stabilizers: + support = tuple(int(q) for q in stabilizer.data_qubits) + positions = [geom.id_to_pos[q] for q in support] + row = sum(pos[0] for pos in positions) / len(positions) + col = sum(pos[1] for pos in positions) / len(positions) + lookup[(stab_type, int(stabilizer.index))] = (support, row, col) + + add(geom.x_stabilizers, "X") + add(geom.z_stabilizers, "Z") + return lookup diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py new file mode 100644 index 000000000..7cd4b355a --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -0,0 +1,406 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Resolved surface-code check-plan metadata.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Any + +from pecos.qec.surface._ancilla_batching import ( + BALANCED_DATA_ANCILLA_SCHEDULE, + DEFAULT_ANCILLA_SCHEDULE, + normalize_ancilla_schedule, +) + +CHECK_PLAN_METADATA_FORMAT = "pecos.surface.check_plan" +CHECK_PLAN_METADATA_VERSION = 1 +CHECK_PLAN_HASH_ALGORITHM = "sha256" +CHECK_PLAN_HASH_SERIALIZATION = "canonical-json-v1" +CURRENT_SURFACE_CHECK_PLAN_RENDERER = "pecos.surface.current_renderer_v1" + +_DEFAULT_CHECK_PLAN_BY_BASIS = { + "cx": "cx_standard_v1", + "szz": "szz_current_v1", +} + + +def _normalize_interaction_basis_name(interaction_basis: str) -> str: + normalized = interaction_basis.lower() + if normalized not in _DEFAULT_CHECK_PLAN_BY_BASIS: + msg = f"interaction_basis must be 'cx' or 'szz', got {interaction_basis!r}" + raise ValueError(msg) + return normalized + + +def _normalize_check_plan_id(check_plan: str) -> str: + normalized = check_plan.lower() + if normalized not in _PLAN_SEMANTICS: + msg = f"unknown check_plan {check_plan!r}; expected one of {sorted(_PLAN_SEMANTICS)}" + raise ValueError(msg) + return normalized + + +_PLAN_SEMANTICS: dict[str, dict[str, Any]] = { + "cx_standard_v1": { + "plan_id": "cx_standard_v1", + "interaction_basis": "cx", + "synthesis_identity": { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + "prefix_policy": "none", + }, + "cx_balanced_data_v1": { + "plan_id": "cx_balanced_data_v1", + "interaction_basis": "cx", + "synthesis_identity": { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + "prefix_policy": "none", + }, + "szz_current_v1": { + "plan_id": "szz_current_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, + "szz_boundary_first_v1": { + "plan_id": "szz_boundary_first_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, + "szz_balanced_data_v1": { + "plan_id": "szz_balanced_data_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, + "szz_boundary_first_balanced_data_v1": { + "plan_id": "szz_boundary_first_balanced_data_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, +} + + +def canonical_check_plan_json(value: dict[str, Any]) -> str: + """Serialize check-plan metadata with stable cross-platform bytes.""" + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def surface_check_plan_ids() -> tuple[str, ...]: + """Return known surface check-plan IDs in deterministic order.""" + return tuple(sorted(_PLAN_SEMANTICS)) + + +def default_surface_check_plan_id(interaction_basis: str | None = None) -> str: + """Return the default check-plan ID for a two-qubit interaction basis.""" + if interaction_basis is None: + return _DEFAULT_CHECK_PLAN_BY_BASIS["cx"] + return _DEFAULT_CHECK_PLAN_BY_BASIS[_normalize_interaction_basis_name(interaction_basis)] + + +def _semantic_hash(semantic_content: dict[str, Any]) -> str: + encoded = canonical_check_plan_json(semantic_content).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class ResolvedSurfaceCheckPlan: + """Internal resolved check-plan metadata for current surface-memory presets.""" + + plan_id: str + interaction_basis: str + synthesis_identity: dict[str, Any] + semantic_content: dict[str, Any] + resolved_metadata: dict[str, Any] + resolved_hash: str + + +def resolve_surface_check_plan( + *, + interaction_basis: str | None = None, + check_plan: str | None = None, +) -> ResolvedSurfaceCheckPlan: + """Resolve the public check-plan selector to deterministic metadata. + + ``check_plan`` is the source of truth. ``interaction_basis`` remains a + backward-compatible default-plan selector and must agree when both are + provided. + """ + normalized_basis = None if interaction_basis is None else _normalize_interaction_basis_name(interaction_basis) + if check_plan is None: + plan_id = _DEFAULT_CHECK_PLAN_BY_BASIS[normalized_basis or "cx"] + else: + plan_id = _normalize_check_plan_id(check_plan) + + semantic_content = json.loads(canonical_check_plan_json(_PLAN_SEMANTICS[plan_id])) + plan_basis = str(semantic_content["interaction_basis"]) + if normalized_basis is not None and normalized_basis != plan_basis: + msg = ( + f"interaction_basis={normalized_basis!r} conflicts with " + f"check_plan={plan_id!r}, which uses interaction_basis={plan_basis!r}" + ) + raise ValueError(msg) + + resolved_hash = _semantic_hash(semantic_content) + resolved_metadata = { + "format": CHECK_PLAN_METADATA_FORMAT, + "metadata_version": CHECK_PLAN_METADATA_VERSION, + "hash_algorithm": CHECK_PLAN_HASH_ALGORITHM, + "hash_serialization": CHECK_PLAN_HASH_SERIALIZATION, + "semantic_content": semantic_content, + } + return ResolvedSurfaceCheckPlan( + plan_id=plan_id, + interaction_basis=plan_basis, + synthesis_identity=dict(semantic_content["synthesis_identity"]), + semantic_content=semantic_content, + resolved_metadata=resolved_metadata, + resolved_hash=resolved_hash, + ) + + +def ancilla_schedule_for_check_plan(resolved_plan: ResolvedSurfaceCheckPlan) -> str: + """Return the concrete ancilla-reuse schedule encoded by a check plan.""" + return normalize_ancilla_schedule( + str( + resolved_plan.synthesis_identity.get( + "ancilla_schedule", + DEFAULT_ANCILLA_SCHEDULE, + ), + ), + ) + + +def require_current_surface_check_plan_renderer( + resolved_plan: ResolvedSurfaceCheckPlan, + *, + context: str, +) -> None: + """Fail if the current source renderers cannot realize ``resolved_plan``. + + Check plans are intended to be source-level circuit contracts, not metadata + labels pasted onto whatever circuit the old ``interaction_basis`` path + happened to emit. Keep this guard close to circuit generation until each new + plan has an explicit renderer. + """ + semantic = resolved_plan.semantic_content + synthesis = resolved_plan.synthesis_identity + schedule = semantic.get("schedule", {}) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + + if resolved_plan.interaction_basis == "cx": + expected_synthesis = { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": ancilla_schedule, + } + else: + expected_synthesis = { + "family": "szz", + "szz_phase_pattern": synthesis.get("szz_phase_pattern"), + "interaction_order": "pecos-default", + "ancilla_schedule": ancilla_schedule, + } + if synthesis.get("szz_phase_pattern") not in {"standard", "boundary-first"}: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; synthesis_identity={synthesis!r}" + ) + raise NotImplementedError(msg) + if ancilla_schedule not in {DEFAULT_ANCILLA_SCHEDULE, BALANCED_DATA_ANCILLA_SCHEDULE}: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; ancilla_schedule={ancilla_schedule!r}" + ) + raise NotImplementedError(msg) + expected_schedule = { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + } + if ancilla_schedule != DEFAULT_ANCILLA_SCHEDULE: + expected_schedule["ancilla_batch_policy"] = ancilla_schedule + if synthesis != expected_synthesis or schedule != expected_schedule: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; synthesis_identity={synthesis!r}, " + f"schedule={schedule!r}" + ) + raise NotImplementedError(msg) + + if resolved_plan.interaction_basis == "cx": + expected_checks = { + "prefix_policy": "none", + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + } + else: + szz_sign_policy_by_pattern = { + "standard": "default_szz_sign_vector_v1", + "boundary-first": "boundary_first_szz_sign_vector_v1", + } + sign_policy = szz_sign_policy_by_pattern[str(synthesis["szz_phase_pattern"])] + expected_checks = { + "prefix_policy": "forward_flow_virtual_z_v1", + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": sign_policy, + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": sign_policy, + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + } + + actual_checks = { + "prefix_policy": semantic.get("prefix_policy"), + "x_check": semantic.get("x_check"), + "z_check": semantic.get("z_check"), + } + if actual_checks != expected_checks: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; check templates={actual_checks!r}" + ) + raise NotImplementedError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py new file mode 100644 index 000000000..7967e670b --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py @@ -0,0 +1,326 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Surface-code Clifford-deformation metadata. + +This module resolves source-level surface-code checks and logical operators +through a concrete local Clifford frame. It intentionally stops before circuit +emission: renderers should consume the resolved Pauli checks instead of +guessing whether a frame can be represented by the legacy CSS X/Z helper. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from collections.abc import Sequence + + from pecos.qec.surface.patch import Stabilizer, SurfacePatch + +PauliAxis = Literal["X", "Y", "Z"] +SurfaceFramePolicy = Literal[ + "identity", + "global_h", + "global_axis_cycle_f", + "global_axis_cycle_f2", + "checkerboard_xzzx", + "checkerboard_zxxz", +] + +_SUPPORTED_GLOBAL_FRAME_POLICIES = frozenset( + { + "identity", + "global_h", + "global_axis_cycle_f", + "global_axis_cycle_f2", + }, +) +_SUPPORTED_CHECKERBOARD_FRAME_POLICIES = frozenset( + { + "checkerboard_xzzx", + "checkerboard_zxxz", + }, +) +_SUPPORTED_FRAME_POLICIES = _SUPPORTED_GLOBAL_FRAME_POLICIES | _SUPPORTED_CHECKERBOARD_FRAME_POLICIES + + +@dataclass(frozen=True, order=True) +class SignedPauli: + """One signed single-qubit Pauli image.""" + + axis: PauliAxis + sign: int = 1 + + def __post_init__(self) -> None: + axis = str(self.axis).upper() + if axis not in {"X", "Y", "Z"}: + msg = f"Pauli axis must be 'X', 'Y', or 'Z', got {self.axis!r}" + raise ValueError(msg) + sign = int(self.sign) + if sign not in {-1, 1}: + msg = f"Pauli sign must be +/-1, got {self.sign!r}" + raise ValueError(msg) + object.__setattr__(self, "axis", axis) + object.__setattr__(self, "sign", sign) + + def label(self) -> str: + """Return a compact signed label such as ``X`` or ``-Y``.""" + return self.axis if self.sign > 0 else f"-{self.axis}" + + +@dataclass(frozen=True) +class LocalCliffordFrame: + """Images of source X and Z under one local Clifford frame.""" + + x_image: SignedPauli + z_image: SignedPauli + + def image(self, source_axis: str) -> SignedPauli: + """Return the signed physical Pauli image for source ``X`` or ``Z``.""" + axis = source_axis.upper() + if axis == "X": + return self.x_image + if axis == "Z": + return self.z_image + msg = f"source_axis must be 'X' or 'Z', got {source_axis!r}" + raise ValueError(msg) + + +@dataclass(frozen=True) +class ResolvedPauliCheck: + """A source stabilizer resolved to concrete physical Pauli axes.""" + + source_kind: PauliAxis + stabilizer_index: int + data_qubits: tuple[int, ...] + paulis: tuple[SignedPauli, ...] + is_boundary: bool + + @property + def axes(self) -> tuple[PauliAxis, ...]: + """Physical Pauli axes in data-qubit order.""" + return tuple(pauli.axis for pauli in self.paulis) + + @property + def signs(self) -> tuple[int, ...]: + """Physical Pauli signs in data-qubit order.""" + return tuple(pauli.sign for pauli in self.paulis) + + @property + def is_uniform_axis(self) -> bool: + """Whether every data qubit is checked in the same physical axis.""" + return len(set(self.axes)) <= 1 + + @property + def uniform_axis(self) -> PauliAxis | None: + """Return the uniform physical axis, or ``None`` for mixed checks.""" + if not self.is_uniform_axis or not self.axes: + return None + return self.axes[0] + + @property + def requires_deformed_check_synthesis(self) -> bool: + """Whether the legacy CSS helper cannot synthesize this check.""" + return self.uniform_axis not in {"X", "Z"} or not self.is_uniform_axis + + +@dataclass(frozen=True) +class ResolvedPauliLogical: + """A source logical operator resolved to concrete physical Pauli axes.""" + + source_kind: PauliAxis + data_qubits: tuple[int, ...] + paulis: tuple[SignedPauli, ...] + + @property + def axes(self) -> tuple[PauliAxis, ...]: + """Physical Pauli axes in data-qubit order.""" + return tuple(pauli.axis for pauli in self.paulis) + + @property + def is_uniform_axis(self) -> bool: + """Whether every data qubit is measured in the same physical axis.""" + return len(set(self.axes)) <= 1 + + @property + def uniform_axis(self) -> PauliAxis | None: + """Return the uniform physical axis, or ``None`` for mixed logicals.""" + if not self.is_uniform_axis or not self.axes: + return None + return self.axes[0] + + +@dataclass(frozen=True) +class ResolvedSurfaceCliffordFrame: + """Resolved source checks/logicals for one concrete surface-code frame.""" + + policy: str + data_frames: tuple[LocalCliffordFrame, ...] + x_checks: tuple[ResolvedPauliCheck, ...] + z_checks: tuple[ResolvedPauliCheck, ...] + logical_x: ResolvedPauliLogical + logical_z: ResolvedPauliLogical + + @property + def checks(self) -> tuple[ResolvedPauliCheck, ...]: + """All resolved checks in source X-then-Z order.""" + return (*self.x_checks, *self.z_checks) + + @property + def requires_deformed_check_synthesis(self) -> bool: + """Whether any check requires the generic deformed-check path.""" + return any(check.requires_deformed_check_synthesis for check in self.checks) + + def css_physical_memory_basis(self, source_basis: str) -> PauliAxis: + """Return the physical X/Z basis if the CSS helper can represent this frame. + + This is intentionally stricter than asking only where the logical memory + axis maps. A global ``F`` frame maps source-Z memory to physical-X + readout, but it also maps source-X stabilizers to physical-Y checks. + Such a circuit is not representable by the current CSS helper and must + use a deformed-check renderer. + """ + basis = source_basis.upper() + if basis == "X": + logical_axis = self.logical_x.uniform_axis + elif basis == "Z": + logical_axis = self.logical_z.uniform_axis + else: + msg = f"source_basis must be 'X' or 'Z', got {source_basis!r}" + raise ValueError(msg) + + if self.requires_deformed_check_synthesis: + msg = ( + f"Frame policy {self.policy!r} requires deformed check synthesis " + "and cannot be represented by the legacy CSS surface helper." + ) + raise NotImplementedError(msg) + if logical_axis not in {"X", "Z"}: + msg = ( + f"Frame policy {self.policy!r} maps source {basis}-memory to " + f"physical {logical_axis}; the CSS helper supports only X/Z." + ) + raise NotImplementedError(msg) + return logical_axis + + +def normalize_surface_frame_policy(policy: str) -> str: + """Normalize and validate a named surface Clifford frame policy.""" + normalized = str(policy).lower().replace("-", "_") + if normalized not in _SUPPORTED_FRAME_POLICIES: + msg = f"unknown surface Clifford frame policy {policy!r}; expected one of {sorted(_SUPPORTED_FRAME_POLICIES)}" + raise ValueError(msg) + return normalized + + +def global_surface_frame(policy: str, num_data: int) -> tuple[LocalCliffordFrame, ...]: + """Return one of the supported parameter-free global frame maps.""" + if num_data < 0: + msg = f"num_data must be non-negative, got {num_data}" + raise ValueError(msg) + normalized = normalize_surface_frame_policy(policy) + if normalized not in _SUPPORTED_GLOBAL_FRAME_POLICIES: + msg = ( + f"surface Clifford frame policy {policy!r} is local; call " + "resolve_surface_clifford_frame(...) with a patch instead" + ) + raise ValueError(msg) + frame = _global_frame_element(normalized) + return tuple(frame for _ in range(num_data)) + + +def resolve_surface_clifford_frame( + patch: SurfacePatch, + *, + policy: str = "identity", + data_frames: Sequence[LocalCliffordFrame] | None = None, +) -> ResolvedSurfaceCliffordFrame: + """Resolve source surface checks/logicals through a local Clifford frame.""" + normalized = normalize_surface_frame_policy(policy) + frames = tuple(data_frames) if data_frames is not None else _surface_frame_for_policy(patch, normalized) + if len(frames) != patch.num_data: + msg = f"data frame length {len(frames)} does not match patch.num_data={patch.num_data}" + raise ValueError(msg) + + def resolve_check(stabilizer: Stabilizer) -> ResolvedPauliCheck: + return ResolvedPauliCheck( + source_kind=stabilizer.stab_type, # type: ignore[arg-type] + stabilizer_index=stabilizer.index, + data_qubits=tuple(stabilizer.data_qubits), + paulis=tuple(frames[q].image(stabilizer.stab_type) for q in stabilizer.data_qubits), + is_boundary=stabilizer.is_boundary, + ) + + geom = patch.geometry + if geom.logical_x is None or geom.logical_z is None: + msg = "Surface patch must have both X and Z logical operators" + raise ValueError(msg) + + return ResolvedSurfaceCliffordFrame( + policy=normalized, + data_frames=frames, + x_checks=tuple(resolve_check(stabilizer) for stabilizer in patch.x_stabilizers), + z_checks=tuple(resolve_check(stabilizer) for stabilizer in patch.z_stabilizers), + logical_x=ResolvedPauliLogical( + source_kind="X", + data_qubits=tuple(geom.logical_x.data_qubits), + paulis=tuple(frames[q].image("X") for q in geom.logical_x.data_qubits), + ), + logical_z=ResolvedPauliLogical( + source_kind="Z", + data_qubits=tuple(geom.logical_z.data_qubits), + paulis=tuple(frames[q].image("Z") for q in geom.logical_z.data_qubits), + ), + ) + + +def _global_frame_element(policy: str) -> LocalCliffordFrame: + if policy == "identity": + return LocalCliffordFrame(SignedPauli("X"), SignedPauli("Z")) + if policy == "global_h": + return LocalCliffordFrame(SignedPauli("Z"), SignedPauli("X")) + if policy == "global_axis_cycle_f": + return LocalCliffordFrame(SignedPauli("Y"), SignedPauli("X")) + if policy == "global_axis_cycle_f2": + return LocalCliffordFrame(SignedPauli("Z"), SignedPauli("Y")) + msg = f"unknown surface Clifford frame policy {policy!r}" + raise ValueError(msg) + + +def _surface_frame_for_policy( + patch: SurfacePatch, + policy: str, +) -> tuple[LocalCliffordFrame, ...]: + """Return the resolved data-qubit frame for a named policy.""" + if policy in _SUPPORTED_GLOBAL_FRAME_POLICIES: + return global_surface_frame(policy, patch.num_data) + if policy in _SUPPORTED_CHECKERBOARD_FRAME_POLICIES: + return _checkerboard_h_frame( + patch, + h_on_even=(policy == "checkerboard_xzzx"), + ) + msg = f"unknown surface Clifford frame policy {policy!r}" + raise ValueError(msg) + + +def _checkerboard_h_frame( + patch: SurfacePatch, + *, + h_on_even: bool, +) -> tuple[LocalCliffordFrame, ...]: + """Return a checkerboard H deformation for rotated surface checks. + + With H on even-parity data sites, source X and Z checks become XZZX in the + data-qubit order used by the rotated-patch stabilizer supports. Flipping the + parity gives the paired ZXXZ orientation. + """ + identity = _global_frame_element("identity") + hadamard = _global_frame_element("global_h") + frames: list[LocalCliffordFrame] = [] + for data_idx in range(patch.num_data): + row, col = patch.geometry.id_to_pos[data_idx] + even = (row + col) % 2 == 0 + frames.append(hadamard if even == h_on_even else identity) + return tuple(frames) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py new file mode 100644 index 000000000..2f531d358 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -0,0 +1,79 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Metadata-driven detection-event extraction for surface memory circuits.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +class _TickCircuitLike(Protocol): + def get_meta(self, key: str) -> str | None: + """Return metadata stored under ``key`` when available.""" + ... + + +def _record_offsets(entry: dict[str, object], num_measurements: int) -> list[int]: + records = entry.get("records") + if records is not None: + return [int(record) for record in records] # type: ignore[union-attr] + meas_ids = entry.get("meas_ids") + if meas_ids is not None: + return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] + msg = "detector/observable metadata entry must define either 'records' or 'meas_ids'" + raise ValueError(msg) + + +def extract_detection_events_and_observables( + tick_circuit: _TickCircuitLike, + results: Iterable[Sequence[int]], +) -> tuple[list[list[int]], list[list[int]]]: + """Extract fired detectors and observables from flat measurement rows.""" + detectors_json = tick_circuit.get_meta("detectors") + detectors = json.loads(detectors_json) if detectors_json else [] + + observables_json = tick_circuit.get_meta("observables") + observables = json.loads(observables_json) if observables_json else [] + + num_meas_meta = tick_circuit.get_meta("num_measurements") + if num_meas_meta is None or num_meas_meta == "": + msg = "extract_detection_events_and_observables requires tick_circuit.get_meta('num_measurements') to be set" + raise ValueError(msg) + num_meas = int(num_meas_meta) + + detection_events_per_shot: list[list[int]] = [] + observable_flips_per_shot: list[list[int]] = [] + + for row in results: + if len(row) != num_meas: + msg = f"result row has length {len(row)} but tick_circuit metadata declares num_measurements={num_meas}" + raise ValueError(msg) + + fired_detectors: list[int] = [] + for det_idx, det in enumerate(detectors): + val = 0 + for rec in _record_offsets(det, num_meas): + idx = num_meas + rec + if 0 <= idx < num_meas: + val ^= int(row[idx]) + if val: + fired_detectors.append(det_idx) + detection_events_per_shot.append(fired_detectors) + + flipped_observables: list[int] = [] + for obs_idx, obs in enumerate(observables): + val = 0 + for rec in _record_offsets(obs, num_meas): + idx = num_meas + rec + if 0 <= idx < num_meas: + val ^= int(row[idx]) + if val: + flipped_observables.append(obs_idx) + observable_flips_per_shot.append(flipped_observables) + + return detection_events_per_shot, observable_flips_per_shot diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py new file mode 100644 index 000000000..8d1ae6b13 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -0,0 +1,156 @@ +"""Configuration objects for Pauli-frame twirling. + +`TwirlConfig` carries the twirl-site declaration: scheme, where in the +circuit twirling sites are emitted, how the per-shot mask is encoded into +the runtime result bundle after the corresponding physical Pauli gates +are applied, how generated Guppy measurement records are framed, and the +runtime activation probability. `scheme`, `site_schedule`, and +`result_encoding` are structural for abstract DEM / topology caches. +`frame_output` and `twirl_probability` are runtime-only: raw/canonical and +scaled/unscaled Guppy records share the same abstract DEM and +`PauliFrameLookup`. + +Scaled twirl uses fixed RNG consumption: generated Guppy draws both an +activation decision and a Pauli code at every site, even when inactive. This +intentionally changed the exact seed-to-mask stream from the pre-scaled +implementation, but preserves same-seed reproducibility within one build and +enables common-random-number comparisons across different activation +probabilities. + +`GuppyRngMaskConfig` carries the **runtime** mask source: a stream-separator +seed mixed with 32 bits of per-shot quantum entropy when the mask is drawn, +applied to data qubits, and recorded via `result()`. Two abstract circuits +identical except for `seed`, `frame_output`, or `twirl_probability` reuse the +same DEM but produce different shot-level runtime records, so those values +belong in the Guppy-module / compiled-shot cache layer but NOT in the abstract +DEM cache. + +The split mirrors the two-tracks-per-twirl-setting architecture from the +design doc: the abstract circuit (consumer: DEM builder, +`PauliFrameLookup`, decoder structure) consumes `TwirlConfig`; the Guppy +module (consumer: Selene runtime) consumes `TwirlConfig` AND +`GuppyRngMaskConfig`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +_SUPPORTED_SCHEMES = ("pauli",) +_SUPPORTED_SITE_SCHEDULES = ("between_rounds", "before_two_qubit_gate") +_SUPPORTED_RESULT_ENCODINGS = ("bool_array_v1",) +_SUPPORTED_FRAME_OUTPUTS = ("raw", "canonical") + + +@dataclass(frozen=True) +class TwirlConfig: + """Structural Pauli-twirl-site declaration. + + All fields are constrained to the values Phase 0a currently supports. + Future Phase 2 (Clifford twirling) work will extend the `scheme` enum. + + Attributes: + scheme: Twirling family. Phase 0a supports `"pauli"`; the + `"clifford"` value is reserved for Phase 2 ({I, H} + Clifford-frame randomization) and not yet implemented. + site_schedule: Where twirling sites are emitted in the circuit. + `"between_rounds"` emits one site between each pair of + consecutive syndrome rounds. `"before_two_qubit_gate"` emits + one site per operand immediately before every surface-memory + two-qubit gate in the supported Guppy runtime path. + result_encoding: How the per-shot mask is recorded in the + runtime result bundle. `"bool_array_v1"` packs the + `2 * num_data` bool bits per round into one tagged array per + twirl site (the only supported encoding -- the earlier + shared-tag scalar-bool variant is unimplementable because + `ShotVec.to_dict()` collapses repeated same-tag calls to the + last value). + frame_output: Runtime Guppy measurement-frame convention. + `"raw"` preserves the landed behavior: measurement tags are + emitted in the physical/twirled frame and callers can + canonicalize with `PauliFrameLookup`. `"canonical"` makes the + generated Guppy program track the Pauli frame classically and + flip emitted measurement bits into the canonical untwirled DEM + frame. This does not change the abstract circuit or DEM + topology; it only changes generated runtime records and must + therefore be part of the Guppy module cache key. + twirl_probability: Per-site activation probability. Runtime Guppy + source draws an activation bit and a Pauli code at every twirl + site. If inactive, the recorded Pauli code is identity. This + changes runtime records and generated source, but not the + abstract DEM / `PauliFrameLookup` structure. + """ + + scheme: Literal["pauli"] = "pauli" + site_schedule: Literal["between_rounds", "before_two_qubit_gate"] = "between_rounds" + result_encoding: Literal["bool_array_v1"] = "bool_array_v1" + frame_output: Literal["raw", "canonical"] = "raw" + twirl_probability: float = 1.0 + + def validate_runtime_supported(self) -> None: + """Raise ``ValueError`` if any field is outside the supported runtime set. + + The ``Literal`` annotations are static-only hints; this method + enforces them at runtime so e.g. + ``TwirlConfig(result_encoding="bool_scalar_v1")`` (constructed via + ``object.__setattr__`` or a stale call path) fails loudly at the + Guppy / harvest boundary rather than silently producing + encoding-incompatible behavior. + """ + if self.scheme not in _SUPPORTED_SCHEMES: + msg = f"TwirlConfig.scheme={self.scheme!r} is not supported; expected one of {_SUPPORTED_SCHEMES!r}" + raise ValueError(msg) + if self.site_schedule not in _SUPPORTED_SITE_SCHEDULES: + msg = ( + f"TwirlConfig.site_schedule={self.site_schedule!r} is not " + f"supported; expected one of {_SUPPORTED_SITE_SCHEDULES!r}" + ) + raise ValueError(msg) + if self.result_encoding not in _SUPPORTED_RESULT_ENCODINGS: + msg = ( + f"TwirlConfig.result_encoding={self.result_encoding!r} is " + f"not supported; expected one of {_SUPPORTED_RESULT_ENCODINGS!r}" + ) + raise ValueError(msg) + if self.frame_output not in _SUPPORTED_FRAME_OUTPUTS: + msg = ( + f"TwirlConfig.frame_output={self.frame_output!r} is not " + f"supported; expected one of {_SUPPORTED_FRAME_OUTPUTS!r}" + ) + raise ValueError(msg) + probability = float(self.twirl_probability) + if not 0.0 <= probability <= 1.0: + msg = ( + f"TwirlConfig.twirl_probability={self.twirl_probability!r} " + "is not supported; expected a finite probability in [0, 1]" + ) + raise ValueError(msg) + + def _validate_runtime_supported(self) -> None: + """Compatibility alias for the public runtime validator.""" + self.validate_runtime_supported() + + +@dataclass(frozen=True) +class GuppyRngMaskConfig: + """Runtime Guppy-side mask source. + + The seed separates mask streams. Generated Guppy programs mix it with + 32 measured H-basis entropy bits per shot before drawing the per-shot + mask at quantum runtime. Excluded from the abstract DEM / topology + cache key by construction: changing the seed must NOT invalidate the + abstract circuit's DEM, only the compiled Guppy module + per-shot mask + buffer. + + The Selene harvest helpers also pass this seed to the Stim simulator + so mask draws are reproducible in tests. Studies that need mask-stream + and syndrome-noise randomness to vary independently should expose a + separate simulator seed. + + Attributes: + seed: 64-bit unsigned stream-separator seed. Must be representable + as a 64-bit unsigned integer. + """ + + seed: int diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py new file mode 100644 index 000000000..02b3663e4 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -0,0 +1,199 @@ +"""Canonical Pauli-twirl site mapping. + +This maps twirl-site declarations for both the abstract circuit +(`circuit_builder.py`) and the Guppy runtime renderer (`pecos.guppy.surface`). + +Both tracks must agree byte-for-byte on the ordering of twirl-site +metadata: the abstract circuit's `tracked_pauli` annotations populate +rows of the `PauliFrameLookup` matrix `M`, and the Guppy program's +runtime Pauli applications plus per-shot `result()` recordings populate +the matching mask columns. If the two paths disagree about which +`(round, qubit)` maps to which row / column, the per-shot XOR +application silently runs the mask against the wrong tracked-Pauli +annotations and the decoder sees an incoherent syndrome. + +The backwards-compatible helpers below describe the `between_rounds` +schedule: `site_idx == round_idx`, one site between each pair of +consecutive syndrome rounds, and one Pauli-mask column per data qubit at +that site. The `before_two_qubit_gate` helpers describe the gate-local +schedule: one tag per two-qubit gate occurrence and two Pauli-mask +columns per tag (control operand then target operand). + +Encoding contract for the runtime mask (``"bool_array_v1"``): + +- One result tag PER twirl site, named via :func:`pauli_mask_round_tag` + (`f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}"`). Each tag is emitted + exactly once per shot, so ``ShotVec.to_dict()`` cannot collapse it. + (An earlier "shared-tag scalar bool" variant was attempted but + unimplementable -- ``ShotVec.to_dict()`` collapses repeated same-tag + ``result()`` calls in one shot to the last value, dropping every + earlier bit on the floor.) +- Each per-round tag carries one bool array of length + ``2 * num_data`` laid out as + ``(qubit_0_lo, qubit_0_hi, qubit_1_lo, qubit_1_hi, ...)``, where + ``lo = (m == 1) | (m == 3)`` and ``hi = (m == 2) | (m == 3)`` for a + Pauli value ``m in {0=I, 1=X, 2=Y, 3=Z}`` drawn at runtime by the + generated Guppy program's inline functional PCG helper and applied as + the matching physical Pauli gate at that twirl site. This is the binary encoding + of the enumeration code, not the Pauli's symplectic X/Z components. +- The decoder + (:func:`pecos.qec.surface.decode._extract_pauli_masks_from_results`) + reads each ``pauli_mask:round:{r}`` tag, packs each ``(lo, hi)`` pair + back into the integer Pauli code via ``m = lo + 2 * hi``, and lays + the result at column :func:`mask_col_for` in row-major + ``(site, qubit)`` order so the output columns match the abstract + ``PauliFrameLookup`` byte-for-byte. +- Side-band result tags emitted by twirl support (`pauli_mask:*`, + `pauli_active:*`, + `frame_mode:*`, `raw:*`) are not detector-bearing measurement tags. + They must never contain ``":meas:"`` and must never be exactly + ``"final"``; handoff result-provenance code treats only the surface + measurement tag grammar and exact ``"final"`` as measurement records. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from pecos.qec.surface.schedule import compute_cnot_schedule + +if TYPE_CHECKING: + from pecos.qec.surface.patch import SurfacePatch + +# Base prefix for per-round mask result tags. The Guppy renderer emits one +# `result(f"{PAULI_MASK_TAG_PREFIX}:round:{r}", array(lo_q0, hi_q0, ...))` +# call per twirl site so each tag fires exactly once per shot (avoids the +# same-tag-multi-call overwrite trap in `ShotVec.to_dict()`). +PAULI_MASK_TAG_PREFIX = "pauli_mask" +PAULI_ACTIVE_TAG_PREFIX = "pauli_active" + +# Backwards-compatible alias for callers that constructed the bare tag. +PAULI_MASK_TAG = PAULI_MASK_TAG_PREFIX + + +def pauli_mask_round_tag(round_idx: int) -> str: + """Return the canonical per-round twirl-mask result tag. + + The tag is emitted between syndrome rounds ``round_idx`` and + ``round_idx + 1``. + """ + return f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}" + + +def pauli_mask_gate_tag(site_idx: int) -> str: + """Return the canonical per-two-qubit-gate twirl-mask result tag.""" + return f"{PAULI_MASK_TAG_PREFIX}:gate:{site_idx}" + + +def pauli_active_round_tag(round_idx: int) -> str: + """Return the canonical per-round twirl-activation result tag.""" + return f"{PAULI_ACTIVE_TAG_PREFIX}:round:{round_idx}" + + +def pauli_active_gate_tag(site_idx: int) -> str: + """Return the canonical per-two-qubit-gate activation result tag.""" + return f"{PAULI_ACTIVE_TAG_PREFIX}:gate:{site_idx}" + + +def num_twirl_sites(num_rounds: int) -> int: + """Number of twirl sites for the `between_rounds` schedule. + + One site between each pair of consecutive syndrome rounds: + `max(0, num_rounds - 1)`. + """ + return max(0, num_rounds - 1) + + +def site_idx_for_round(round_idx: int) -> int: + """Map the preceding round-loop index to a twirl site index. + + For the `between_rounds` schedule, the twirl site that sits between + syndrome round `r` and round `r + 1` is canonical `site_idx == r`. + Identity by construction; abstracted so a future schedule can override. + """ + return round_idx + + +def num_mask_bits_per_round(num_data: int) -> int: + """Number of bool bits the Guppy program emits per twirl site. + + The bool-bits encoding uses two bits per data qubit (`lo, hi`) so the + per-site contribution is `2 * num_data` bools. + """ + return 2 * num_data + + +def num_mask_bits_per_shot(num_rounds: int, num_data: int) -> int: + """Total bool-bit count one shot's `pauli_mask` result accumulates.""" + return num_twirl_sites(num_rounds) * num_mask_bits_per_round(num_data) + + +def num_pauli_sites(num_rounds: int, num_data: int) -> int: + """Number of mask columns (one per (site, data qubit) cell). + + Matches the column count of the abstract `PauliFrameLookup` matrix `M` + when twirling is enabled. + """ + return num_twirl_sites(num_rounds) * num_data + + +def mask_col_for(site_idx: int, qubit_idx: int, num_data: int) -> int: + """Canonical flat (site, qubit) -> mask column index. + + Row-major over (site, qubit). Decoder-side `sample_pauli_masks_from_guppy` + uses this to assemble the `(num_shots, num_pauli_sites)` u8 array from a + flat bool stream. + """ + return site_idx * num_data + qubit_idx + + +def mask_col_for_gate_operand(site_idx: int, operand_idx: int) -> int: + """Canonical flat (gate site, operand) -> mask column index. + + Operand order is the physical two-qubit gate order: control first, + target second. Each gate-local twirl site therefore contributes two + integer Pauli-code columns. + """ + if operand_idx not in (0, 1): + msg = f"operand_idx must be 0 or 1, got {operand_idx}" + raise ValueError(msg) + return site_idx * 2 + operand_idx + + +def num_two_qubit_gate_twirl_sites( + patch: SurfacePatch, + *, + num_rounds: int, + basis: str, +) -> int: + """Number of two-qubit gate occurrences twirled by the gate-local schedule.""" + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + cnot_rounds = compute_cnot_schedule(patch) + init_gates = sum( + 1 + for cx_round in cnot_rounds + for stab_type, _stab_idx, _data_idx in cx_round + if stab_type == init_stabilizer_type + ) + counted_round_gates = sum(len(cx_round) for cx_round in cnot_rounds) * num_rounds + return init_gates + counted_round_gates + + +def num_pauli_sites_for_schedule( + patch: SurfacePatch, + *, + num_rounds: int, + basis: str, + site_schedule: Literal["between_rounds", "before_two_qubit_gate"] = "between_rounds", +) -> int: + """Number of integer Pauli-code columns for a schedule.""" + if site_schedule == "between_rounds": + return num_pauli_sites(num_rounds, patch.geometry.num_data) + if site_schedule == "before_two_qubit_gate": + return 2 * num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + msg = f"unsupported Pauli-twirl site_schedule={site_schedule!r}" + raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b50b51718..9c945abe0 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -21,6 +21,9 @@ from enum import Enum, auto from typing import TYPE_CHECKING, TypedDict +if TYPE_CHECKING: + from collections.abc import Mapping + # `_batched_stabilizers` and `_normalize_ancilla_budget` are imported from # the shared `_ancilla_batching` helper so this builder and the Guppy # emitter (`pecos.guppy.surface`) compute identical batches by @@ -32,6 +35,14 @@ from pecos.qec.surface._ancilla_batching import ( normalize_ancilla_budget as _normalize_ancilla_budget, ) +from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + resolve_surface_check_plan, +) +from pecos.qec.surface._clifford_deformation import ( + resolve_surface_clifford_frame, +) # Stabilizer geometry helpers live in the low-level patch module (single # source of truth). Only the two used by the circuit renderer are imported @@ -40,8 +51,16 @@ get_stabilizer_region, get_stabilizer_touch_label, ) +from pecos.quantum import PHYSICAL_DURATION_META_KEY if TYPE_CHECKING: + from pecos.qec.surface._check_plan import ResolvedSurfaceCheckPlan + from pecos.qec.surface._clifford_deformation import ( + PauliAxis, + ResolvedPauliCheck, + ResolvedSurfaceCliffordFrame, + ) + from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import ( LogicalDescriptor, StabilizerDescriptor, @@ -111,11 +130,21 @@ class OpType(Enum): # Single-qubit gates H = auto() # Hadamard + F = auto() # face Clifford + FDG = auto() # face Clifford dagger + SX = auto() # sqrt X + SXDG = auto() # sqrt X dagger + SY = auto() # sqrt Y + SYDG = auto() # sqrt Y dagger + SZ = auto() # sqrt Z / phase + SZDG = auto() # sqrt Z dagger X = auto() # Pauli X Z = auto() # Pauli Z # Two-qubit gates CX = auto() # CNOT + SZZ = auto() # sqrt ZZ + SZZDG = auto() # sqrt ZZ dagger # Measurement MEASURE = auto() # Destructive measurement @@ -124,6 +153,13 @@ class OpType(Enum): TICK = auto() # Layer separator COMMENT = auto() # Comment/annotation + # Annotation: declares a candidate tracked Pauli at this circuit position. + # The propagator records its forward propagation to detectors and + # observables; per-shot "did this Pauli fire?" is consumed at sampling + # time. ``qubits`` is the single data qubit the Pauli acts on; ``label`` + # carries the Pauli kind and site as ``"{X|Y|Z}@s"``. + TRACKED_PAULI = auto() + @dataclass class SurfaceCircuitStep: @@ -148,18 +184,710 @@ def total(self) -> int: return len(set(self.data_qubits) | set(self.x_ancilla_qubits) | set(self.z_ancilla_qubits)) +@dataclass(frozen=True) +class SzzForwardFlowPulse: + """One pending-Clifford discharge site in the SZZ device-flow model.""" + + host_index: int + host_op_type: str + host_label: str + qubit: int + kind: str + pending_clifford: str + + +@dataclass(frozen=True) +class SzzForwardFlowSummary: + """Pulse accounting for the SZZ pending-Clifford forward-flow model.""" + + abstract_single_qubit_ops: int + physical_prefix_pulses: int + two_qubit_prefix_pulses: int + measurement_prefix_pulses: int + virtual_z_two_qubit_carries: int + virtual_z_measure_discards: int + two_qubit_gates: int + measurements: int + prep_events: int + free_standing_single_qubit_ops: int + pulses: tuple[SzzForwardFlowPulse, ...] + + +@dataclass(frozen=True, order=True) +class SzzTouchSign: + """Signed SZZ touch in the v1 surface-memory sign convention.""" + + stabilizer_type: str + stabilizer_index: int + data_qubit: int + sign: int + + +@dataclass(frozen=True, order=True) +class SzzBoundaryCompensation: + """Analysis-only compensation for an odd uncompensated boundary residual.""" + + stabilizer_type: str + stabilizer_index: int + data_qubit: int + gate: str + + +@dataclass(frozen=True, order=True) +class SzzClass2Residual: + """Analysis-only class-2 data residual of the uncompensated sign vector.""" + + stabilizer_type: str + data_qubit: int + pauli: str + + +@dataclass(frozen=True) +class SzzResidualPlan: + """Validated SZZ sign convention and uncompensated residual bookkeeping. + + The active SZZ template cancels data residuals with immediate per-touch + compensation; no class-2 residual stream is emitted by the circuit. + """ + + signs: tuple[SzzTouchSign, ...] + boundary_compensations: tuple[SzzBoundaryCompensation, ...] + class2_residuals: tuple[SzzClass2Residual, ...] + + +def _normalize_interaction_basis(interaction_basis: str) -> str: + """Validate and normalize the surface two-qubit interaction basis.""" + normalized = interaction_basis.lower() + if normalized not in {"cx", "szz"}: + msg = f"interaction_basis must be 'cx' or 'szz', got {interaction_basis!r}" + raise ValueError(msg) + return normalized + + +def _szz_residual_class(sum_signs: int) -> str: + """Classify a signed residual sum modulo four.""" + residue = sum_signs % 4 + if residue == 0: + return "identity" + if residue == 2: + return "pauli" + return "odd" + + +def _iter_surface_stabilizer_touches(patch: SurfacePatch) -> list[tuple[str, int, tuple[int, ...], bool]]: + """Return stabilizer touch rows in deterministic X-then-Z order.""" + geom = patch.geometry + rows: list[tuple[str, int, tuple[int, ...], bool]] = [] + rows.extend(("X", stab.index, tuple(stab.data_qubits), bool(stab.is_boundary)) for stab in geom.x_stabilizers) + rows.extend(("Z", stab.index, tuple(stab.data_qubits), bool(stab.is_boundary)) for stab in geom.z_stabilizers) + return rows + + +def _default_szz_sign_vector(patch: SurfacePatch) -> tuple[SzzTouchSign, ...]: + """Return the v1 hard-coded SZZ sign vector. + + Bulk checks use all ``SZZ``. Boundary checks use one ``SZZdg`` on the + second data operand, giving ancilla class 0 while preserving a stable, + geometry-derived convention. + """ + signs: list[SzzTouchSign] = [] + for stabilizer_type, stabilizer_index, data_qubits, is_boundary in _iter_surface_stabilizer_touches(patch): + if is_boundary and len(data_qubits) != 2: + msg = ( + "SZZ v1 expects boundary stabilizers to have weight 2; " + f"{stabilizer_type}{stabilizer_index} has weight {len(data_qubits)}" + ) + raise ValueError(msg) + for touch_index, data_qubit in enumerate(data_qubits): + sign = -1 if is_boundary and touch_index == 1 else 1 + signs.append( + SzzTouchSign( + stabilizer_type=stabilizer_type, + stabilizer_index=stabilizer_index, + data_qubit=data_qubit, + sign=sign, + ), + ) + return tuple(sorted(signs)) + + +def _boundary_first_szz_sign_vector(patch: SurfacePatch) -> tuple[SzzTouchSign, ...]: + """Return the SZZ sign vector with boundary daggers on first operands. + + This is the first non-default SZZ/SZZdg source-level check plan. It keeps + the same schedule and compensation model as the default plan but changes + the concrete signed SZZ touch chosen on each weight-2 boundary check. + """ + signs: list[SzzTouchSign] = [] + for stabilizer_type, stabilizer_index, data_qubits, is_boundary in _iter_surface_stabilizer_touches(patch): + if is_boundary and len(data_qubits) != 2: + msg = ( + "SZZ boundary-first expects boundary stabilizers to have weight 2; " + f"{stabilizer_type}{stabilizer_index} has weight {len(data_qubits)}" + ) + raise ValueError(msg) + for touch_index, data_qubit in enumerate(data_qubits): + sign = -1 if is_boundary and touch_index == 0 else 1 + signs.append( + SzzTouchSign( + stabilizer_type=stabilizer_type, + stabilizer_index=stabilizer_index, + data_qubit=data_qubit, + sign=sign, + ), + ) + return tuple(sorted(signs)) + + +def _validate_szz_sign_vector( + patch: SurfacePatch, + signs: tuple[SzzTouchSign, ...], +) -> SzzResidualPlan: + """Validate an SZZ sign vector and derive fixed residual bookkeeping.""" + expected_keys = { + (stabilizer_type, stabilizer_index, data_qubit) + for stabilizer_type, stabilizer_index, data_qubits, _is_boundary in _iter_surface_stabilizer_touches(patch) + for data_qubit in data_qubits + } + seen_keys: set[tuple[str, int, int]] = set() + check_sums: dict[tuple[str, int], int] = {} + data_sums: dict[tuple[str, int], int] = {} + data_touch_counts: dict[tuple[str, int], int] = {} + + for entry in signs: + if entry.sign not in {-1, 1}: + msg = f"SZZ sign entries must be +/-1, got {entry.sign!r} for {entry}" + raise ValueError(msg) + key = (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit) + if key in seen_keys: + msg = f"duplicate SZZ sign entry for touch {key}" + raise ValueError(msg) + seen_keys.add(key) + check_key = (entry.stabilizer_type, entry.stabilizer_index) + data_key = (entry.stabilizer_type, entry.data_qubit) + check_sums[check_key] = check_sums.get(check_key, 0) + entry.sign + data_sums[data_key] = data_sums.get(data_key, 0) + entry.sign + data_touch_counts[data_key] = data_touch_counts.get(data_key, 0) + 1 + + missing = sorted(expected_keys - seen_keys) + extra = sorted(seen_keys - expected_keys) + if missing or extra: + msg = f"SZZ sign vector must cover exactly the surface touches; missing={missing}, extra={extra}" + raise ValueError(msg) + + for (stabilizer_type, stabilizer_index), sum_signs in sorted(check_sums.items()): + residual_class = _szz_residual_class(sum_signs) + if residual_class != "identity": + msg = ( + "SZZ sign vector rejected: " + f"{stabilizer_type}{stabilizer_index} ancilla residual is {residual_class} " + f"(sum={sum_signs}, mod4={sum_signs % 4}); v1 has no record-flip checks" + ) + raise ValueError(msg) + + compensations: list[SzzBoundaryCompensation] = [] + class2_residuals: list[SzzClass2Residual] = [] + for (stabilizer_type, data_qubit), sum_signs in sorted(data_sums.items()): + residual_class = _szz_residual_class(sum_signs) + if residual_class == "identity": + continue + if residual_class == "pauli": + class2_residuals.append( + SzzClass2Residual( + stabilizer_type=stabilizer_type, + data_qubit=data_qubit, + pauli="X" if stabilizer_type == "X" else "Z", + ), + ) + continue + if data_touch_counts[(stabilizer_type, data_qubit)] != 1: + msg = ( + "SZZ sign vector rejected: odd residual on a non-boundary data class " + f"{stabilizer_type}, data={data_qubit}, sum={sum_signs}" + ) + raise ValueError(msg) + touch = next( + entry for entry in signs if entry.stabilizer_type == stabilizer_type and entry.data_qubit == data_qubit + ) + gate = { + ("X", 1): "SXDG", + ("X", -1): "SX", + ("Z", 1): "SZDG", + ("Z", -1): "SZ", + }[(stabilizer_type, touch.sign)] + compensations.append( + SzzBoundaryCompensation( + stabilizer_type=touch.stabilizer_type, + stabilizer_index=touch.stabilizer_index, + data_qubit=touch.data_qubit, + gate=gate, + ), + ) + + return SzzResidualPlan( + signs=tuple(sorted(signs)), + boundary_compensations=tuple(sorted(compensations)), + class2_residuals=tuple(sorted(class2_residuals)), + ) + + +def _default_szz_residual_plan(patch: SurfacePatch) -> SzzResidualPlan: + """Return the validated v1 SZZ residual plan for a patch.""" + return _validate_szz_sign_vector(patch, _default_szz_sign_vector(patch)) + + +def _szz_residual_plan_for_check_plan( + patch: SurfacePatch, + resolved_plan: ResolvedSurfaceCheckPlan, +) -> SzzResidualPlan: + """Return the concrete SZZ residual plan for a resolved check plan.""" + if resolved_plan.interaction_basis != "szz": + msg = f"SZZ residual plans require interaction_basis='szz', got {resolved_plan.interaction_basis!r}" + raise ValueError(msg) + + pattern = str(resolved_plan.synthesis_identity["szz_phase_pattern"]) + if pattern == "standard": + return _default_szz_residual_plan(patch) + if pattern == "boundary-first": + return _validate_szz_sign_vector(patch, _boundary_first_szz_sign_vector(patch)) + + msg = f"unsupported SZZ phase pattern {pattern!r} for check_plan={resolved_plan.plan_id!r}" + raise NotImplementedError(msg) + + +def _resolve_szz_clifford_frame_for_builder( + patch: SurfacePatch, + *, + interaction_basis: str, + clifford_frame_policy: str | None, +) -> ResolvedSurfaceCliffordFrame | None: + """Resolve an optional source-level Clifford frame for SZZ rendering.""" + if clifford_frame_policy is None: + return None + if interaction_basis != "szz": + msg = "clifford_frame_policy currently requires interaction_basis='szz'" + raise NotImplementedError(msg) + return resolve_surface_clifford_frame(patch, policy=clifford_frame_policy) + + +def _szz_memory_physical_axis( + basis: str, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None, +) -> PauliAxis: + """Return the uniform physical axis for a source memory basis, if any.""" + source_basis = basis.upper() + if source_basis not in {"X", "Z"}: + msg = f"basis must be 'X' or 'Z', got {basis!r}" + raise ValueError(msg) + if resolved_clifford_frame is None: + return source_basis # type: ignore[return-value] + + axes = {frame.image(source_basis).axis for frame in resolved_clifford_frame.data_frames} + if len(axes) != 1: + msg = ( + f"clifford frame policy {resolved_clifford_frame.policy!r} maps " + f"source {source_basis}-memory to mixed data measurement axes " + f"{sorted(axes)}; call _szz_memory_physical_axis_for_data instead" + ) + raise NotImplementedError(msg) + return next(iter(axes)) + + +def _szz_memory_physical_axis_for_data( + basis: str, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None, + data_idx: int, +) -> PauliAxis: + """Return the physical prep/readout axis for one source-basis data qubit.""" + source_basis = basis.upper() + if source_basis not in {"X", "Z"}: + msg = f"basis must be 'X' or 'Z', got {basis!r}" + raise ValueError(msg) + if resolved_clifford_frame is None: + return source_basis # type: ignore[return-value] + try: + frame = resolved_clifford_frame.data_frames[data_idx] + except IndexError as exc: + msg = ( + f"data qubit {data_idx} is outside resolved frame with " + f"{len(resolved_clifford_frame.data_frames)} data frames" + ) + raise ValueError(msg) from exc + return frame.image(source_basis).axis + + +def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through uncompensated SZZ/SZZdg.""" + common = x_a ^ x_b + return x_a, z_a ^ common, x_b, z_b ^ common + + +def _propagate_compensated_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through the compensated CZ-equivalent interaction.""" + return x_a, z_a ^ x_b, x_b, z_b ^ x_a + + +def _propagate_sxx_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through the SXX/SXXdg mirror.""" + common = z_a ^ z_b + return x_a ^ common, z_a, x_b ^ common, z_b + + +_SIGNED_PAULI_NAMES = { + 1: "X", + -1: "-X", + 2: "Y", + -2: "-Y", + 3: "Z", + -3: "-Z", +} +_SZZ_FLOW_IDENTITY: tuple[int, int] = (1, 3) +_SZZ_FLOW_SINGLE_QUBIT_GATES = { + OpType.H, + OpType.SX, + OpType.SXDG, + OpType.SZ, + OpType.SZDG, + OpType.X, + OpType.Z, +} +_SZZ_FLOW_GATE_ACTIONS: dict[OpType, dict[int, int]] = { + OpType.H: {1: 3, 2: -2, 3: 1}, + OpType.F: {1: 2, 2: 3, 3: 1}, + OpType.FDG: {1: 3, 2: 1, 3: 2}, + OpType.SX: {1: 1, 2: 3, 3: -2}, + OpType.SXDG: {1: 1, 2: -3, 3: 2}, + OpType.SY: {1: -3, 2: 2, 3: 1}, + OpType.SYDG: {1: 3, 2: 2, 3: -1}, + OpType.SZ: {1: 2, 2: -1, 3: 3}, + OpType.SZDG: {1: -2, 2: 1, 3: 3}, + OpType.X: {1: 1, 2: -2, 3: -3}, + OpType.Z: {1: -1, 2: -2, 3: 3}, +} + + +def _szz_flow_apply_gate_to_pauli(op_type: OpType, pauli: int) -> int: + sign = -1 if pauli < 0 else 1 + return sign * _SZZ_FLOW_GATE_ACTIONS[op_type][abs(pauli)] + + +def _szz_flow_compose_pending_gate(pending: tuple[int, int], op_type: OpType) -> tuple[int, int]: + """Append one 1q Clifford to a pending signed-Pauli-image register.""" + return ( + _szz_flow_apply_gate_to_pauli(op_type, pending[0]), + _szz_flow_apply_gate_to_pauli(op_type, pending[1]), + ) + + +def _szz_flow_is_virtual_z(pending: tuple[int, int]) -> bool: + """Return whether a pending Clifford is a zero-pulse virtual-Z update.""" + return pending[1] == 3 + + +def _szz_flow_clifford_name(pending: tuple[int, int]) -> str: + return f"X->{_SIGNED_PAULI_NAMES[pending[0]]},Z->{_SIGNED_PAULI_NAMES[pending[1]]}" + + +def _analyze_szz_forward_flow(ops: list[SurfaceCircuitStep]) -> SzzForwardFlowSummary: + """Analyze SZZ pending-Clifford forward-flow pulse accounting. + + The abstract SZZ template intentionally contains free-standing 1q + Cliffords. The SZZ device model executes those Cliffords only as prefixes + of the next SZZ/SZZdg or MZ on that qubit. Virtual-Z pending Cliffords carry + through SZZ/SZZdg and are discarded at MZ. + """ + pending_by_qubit: dict[int, tuple[int, int]] = {} + pulses: list[SzzForwardFlowPulse] = [] + abstract_single_qubit_ops = 0 + physical_prefix_pulses = 0 + two_qubit_prefix_pulses = 0 + measurement_prefix_pulses = 0 + virtual_z_two_qubit_carries = 0 + virtual_z_measure_discards = 0 + two_qubit_gates = 0 + measurements = 0 + prep_events = 0 + + def pending_for(q: int) -> tuple[int, int]: + return pending_by_qubit.setdefault(q, _SZZ_FLOW_IDENTITY) + + def pulse_event( + *, + host_index: int, + op: SurfaceCircuitStep, + qubit: int, + kind: str, + pending: tuple[int, int], + ) -> SzzForwardFlowPulse: + return SzzForwardFlowPulse( + host_index=host_index, + host_op_type=op.op_type.name, + host_label=op.label, + qubit=qubit, + kind=kind, + pending_clifford=_szz_flow_clifford_name(pending), + ) + + def reset_for_prep(q: int, op: SurfaceCircuitStep) -> None: + current = pending_for(q) + if current != _SZZ_FLOW_IDENTITY: + msg = ( + "SZZ forward-flow cannot reset a qubit with a pending " + f"Clifford: q={q}, pending={_szz_flow_clifford_name(current)}, " + f"op={op.op_type.name} {op.label!r}" + ) + raise ValueError(msg) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge_for_two_qubit(q: int, host_index: int, op: SurfaceCircuitStep) -> None: + nonlocal physical_prefix_pulses, two_qubit_prefix_pulses, virtual_z_two_qubit_carries + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return + if _szz_flow_is_virtual_z(current): + virtual_z_two_qubit_carries += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="virtual_z_two_qubit_carry", + pending=current, + ), + ) + return + physical_prefix_pulses += 1 + two_qubit_prefix_pulses += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="physical_two_qubit_prefix", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge_for_measurement(q: int, host_index: int, op: SurfaceCircuitStep) -> None: + nonlocal physical_prefix_pulses, measurement_prefix_pulses, virtual_z_measure_discards + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return + if _szz_flow_is_virtual_z(current): + virtual_z_measure_discards += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="virtual_z_measure_discard", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + return + physical_prefix_pulses += 1 + measurement_prefix_pulses += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="physical_measurement_prefix", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + for host_index, op in enumerate(ops): + if op.op_type in {OpType.COMMENT, OpType.TICK, OpType.TRACKED_PAULI}: + continue + if op.op_type in {OpType.ALLOC, OpType.PREP}: + prep_events += 1 + reset_for_prep(op.qubits[0], op) + continue + if op.op_type in _SZZ_FLOW_SINGLE_QUBIT_GATES: + q = op.qubits[0] + abstract_single_qubit_ops += 1 + pending_by_qubit[q] = _szz_flow_compose_pending_gate(pending_for(q), op.op_type) + continue + if op.op_type in {OpType.SZZ, OpType.SZZDG}: + two_qubit_gates += 1 + for q in op.qubits: + discharge_for_two_qubit(q, host_index, op) + continue + if op.op_type == OpType.CX: + msg = "SZZ forward-flow analysis only supports SZZ/SZZdg two-qubit gates" + raise ValueError(msg) + if op.op_type == OpType.MEASURE: + measurements += 1 + discharge_for_measurement(op.qubits[0], host_index, op) + continue + + remaining = {q: pending for q, pending in pending_by_qubit.items() if pending != _SZZ_FLOW_IDENTITY} + if remaining: + formatted = {q: _szz_flow_clifford_name(pending) for q, pending in sorted(remaining.items())} + msg = f"SZZ forward-flow ended with pending Cliffords: {formatted}" + raise ValueError(msg) + + return SzzForwardFlowSummary( + abstract_single_qubit_ops=abstract_single_qubit_ops, + physical_prefix_pulses=physical_prefix_pulses, + two_qubit_prefix_pulses=two_qubit_prefix_pulses, + measurement_prefix_pulses=measurement_prefix_pulses, + virtual_z_two_qubit_carries=virtual_z_two_qubit_carries, + virtual_z_measure_discards=virtual_z_measure_discards, + two_qubit_gates=two_qubit_gates, + measurements=measurements, + prep_events=prep_events, + free_standing_single_qubit_ops=0, + pulses=tuple(pulses), + ) + + +_SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING: dict[tuple[int, int], tuple[OpType | None, OpType]] = { + (3, 1): (None, OpType.H), + (-3, 1): (None, OpType.SY), + (2, 1): (None, OpType.F), + (-2, 1): (OpType.Z, OpType.F), + (1, 2): (None, OpType.SXDG), + (-1, 2): (OpType.Z, OpType.SXDG), + (3, 2): (None, OpType.FDG), + (-3, 2): (OpType.Z, OpType.FDG), +} + + +def _lower_szz_forward_flow_ops(ops: list[SurfaceCircuitStep]) -> list[SurfaceCircuitStep]: + """Return an SZZ physical-prefix TickCircuit op stream. + + Free-standing single-qubit Cliffords in the abstract SZZ template are + accumulated into a pending local Clifford and discharged as a physical + prefix pulse on the next SZZ/SZZdg or MZ host. Zero-noise Z-frame gates are + emitted only when needed to preserve the exact signed Clifford before a + physical prefix pulse. + """ + pending_by_qubit: dict[int, tuple[int, int]] = {} + lowered: list[SurfaceCircuitStep] = [] + + def pending_for(q: int) -> tuple[int, int]: + return pending_by_qubit.setdefault(q, _SZZ_FLOW_IDENTITY) + + def reset_for_prep(q: int, op: SurfaceCircuitStep) -> None: + current = pending_for(q) + if current != _SZZ_FLOW_IDENTITY: + msg = ( + "SZZ forward-flow cannot reset a qubit with a pending " + f"Clifford: q={q}, pending={_szz_flow_clifford_name(current)}, " + f"op={op.op_type.name} {op.label!r}" + ) + raise ValueError(msg) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge(q: int, host: SurfaceCircuitStep) -> tuple[SurfaceCircuitStep | None, SurfaceCircuitStep | None]: + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return None, None + if _szz_flow_is_virtual_z(current): + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY if host.op_type == OpType.MEASURE else current + return None, None + try: + virtual_gate, physical_gate = _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[current] + except KeyError as exc: + msg = ( + "SZZ forward-flow cannot lower pending Clifford " + f"{_szz_flow_clifford_name(current)} on q={q} before " + f"{host.op_type.name} {host.label!r}" + ) + raise ValueError(msg) from exc + virtual_step = None + if virtual_gate is not None: + virtual_step = SurfaceCircuitStep( + virtual_gate, + [q], + f"szz_virtual_prefix:{virtual_gate.name}:{host.label}:q{q}", + ) + physical_step = SurfaceCircuitStep( + physical_gate, + [q], + f"szz_physical_prefix:{physical_gate.name}:{host.label}:q{q}", + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + return virtual_step, physical_step + + def append_prefix_ticks(virtual_steps: list[SurfaceCircuitStep], physical_steps: list[SurfaceCircuitStep]) -> None: + if virtual_steps: + lowered.append(SurfaceCircuitStep(OpType.TICK)) + lowered.extend(virtual_steps) + lowered.append(SurfaceCircuitStep(OpType.TICK)) + if physical_steps: + lowered.append(SurfaceCircuitStep(OpType.TICK)) + lowered.extend(physical_steps) + lowered.append(SurfaceCircuitStep(OpType.TICK)) + + for op in ops: + if op.op_type in {OpType.COMMENT, OpType.TICK, OpType.TRACKED_PAULI}: + lowered.append(op) + continue + if op.op_type in {OpType.ALLOC, OpType.PREP}: + reset_for_prep(op.qubits[0], op) + lowered.append(op) + continue + if op.op_type in _SZZ_FLOW_SINGLE_QUBIT_GATES: + q = op.qubits[0] + pending_by_qubit[q] = _szz_flow_compose_pending_gate(pending_for(q), op.op_type) + continue + if op.op_type in {OpType.SZZ, OpType.SZZDG}: + virtual_steps: list[SurfaceCircuitStep] = [] + physical_steps: list[SurfaceCircuitStep] = [] + for q in op.qubits: + virtual_step, physical_step = discharge(q, op) + if virtual_step is not None: + virtual_steps.append(virtual_step) + if physical_step is not None: + physical_steps.append(physical_step) + append_prefix_ticks(virtual_steps, physical_steps) + lowered.append(op) + continue + if op.op_type == OpType.CX: + msg = "SZZ forward-flow lowering only supports SZZ/SZZdg two-qubit gates" + raise ValueError(msg) + if op.op_type == OpType.MEASURE: + virtual_step, physical_step = discharge(op.qubits[0], op) + append_prefix_ticks( + [] if virtual_step is None else [virtual_step], + [] if physical_step is None else [physical_step], + ) + lowered.append(op) + continue + lowered.append(op) + + remaining = {q: pending for q, pending in pending_by_qubit.items() if pending != _SZZ_FLOW_IDENTITY} + if remaining: + formatted = {q: _szz_flow_clifford_name(pending) for q, pending in sorted(remaining.items())} + msg = f"SZZ forward-flow lowering ended with pending Cliffords: {formatted}" + raise ValueError(msg) + return lowered + + def build_surface_code_circuit( patch: SurfacePatch, num_rounds: int, basis: str = "Z", ancilla_budget: int | None = None, + *, + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. This generates the circuit structure matching the Guppy implementation: 1. prep_{basis}_basis: Allocate and prepare data qubits - 2. syndrome_extraction x num_rounds: Syndrome extraction with fresh ancillas - 3. measure_{basis}_basis: Final data qubit measurement + 2. init syndrome establishment for the random-sign stabilizer family + 3. syndrome_extraction x num_rounds: Syndrome extraction with fresh ancillas + 4. measure_{basis}_basis: Final data qubit measurement Args: patch: Surface code patch with geometry @@ -168,6 +896,23 @@ def build_surface_code_circuit( ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, ancillas are reused across stabilizer batches following the public Guppy order. + twirl: When provided, emit three ``OpType.TRACKED_PAULI`` annotations + (``X``, ``Y``, ``Z``) per Pauli-mask column. The + ``"between_rounds"`` schedule emits one column per data qubit at + each site between counted syndrome rounds. The + ``"before_two_qubit_gate"`` schedule emits one column per operand + immediately before each surface-memory two-qubit gate. + interaction_basis: Surface-memory two-qubit interaction basis. + ``"cx"`` preserves the existing CNOT extraction circuit. ``"szz"`` + emits the direct-renderer SZZ/SZZdg abstract template with local + data-qubit compensation. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy. Currently supported only by the SZZ renderer. Global + axis-cycle frames and checkerboard XZZX/ZXXZ frames are rendered + as concrete deformed checks. Returns: Tuple of (operations list, qubit allocation info) @@ -180,6 +925,24 @@ def build_surface_code_circuit( num_z_anc = len(geom.z_stabilizers) total_ancilla = num_x_anc + num_z_anc effective_ancilla_budget = _normalize_ancilla_budget(total_ancilla, ancilla_budget) + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="abstract surface-code circuit generation", + ) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) + resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) + if twirl is not None: + twirl.validate_runtime_supported() + twirl_site_schedule = None if twirl is None else twirl.site_schedule # Qubit allocation layout. Under ancilla reuse, stabilizers map onto a # shared ancilla pool and different stabilizers can intentionally share the @@ -196,7 +959,11 @@ def build_surface_code_circuit( ancilla_pool = list(range(num_data, num_data + effective_ancilla_budget)) x_ancilla_qubits = [-1] * num_x_anc z_ancilla_qubits = [-1] * num_z_anc - for batch in _batched_stabilizers(patch, effective_ancilla_budget): + for batch in _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ): for pool_idx, (stab_type, stab_idx) in enumerate(batch): if stab_type == "X": x_ancilla_qubits[stab_idx] = ancilla_pool[pool_idx] @@ -218,9 +985,71 @@ def x_anc_q(stab_idx: int) -> int: def z_anc_q(stab_idx: int) -> int: return allocation.z_ancilla_qubits[stab_idx] + def emit_between_round_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) -> None: + """Append 3 * num_data candidate tracked-Pauli annotations.""" + target_ops.extend( + SurfaceCircuitStep(OpType.TRACKED_PAULI, [data_q(i)], f"{kind}@s{site_idx}") + for i in range(num_data) + for kind in ("X", "Y", "Z") + ) + + gate_twirl_site_idx = 0 + + def emit_gate_local_twirl_site( + target_ops: list[SurfaceCircuitStep], + site_idx: int, + control_q: int, + target_q: int, + ) -> None: + """Append tracked-Pauli annotations for one two-qubit-gate site.""" + for operand_idx, q in enumerate((control_q, target_q)): + target_ops.extend( + SurfaceCircuitStep( + OpType.TRACKED_PAULI, + [q], + f"{kind}@g{site_idx}o{operand_idx}", + ) + for kind in ("X", "Y", "Z") + ) + + def emit_gate_local_twirl_layer( + target_ops: list[SurfaceCircuitStep], + cx_ops: list[tuple[int, int, str]], + ) -> None: + """Append all gate-local twirl annotations before a parallel CX layer.""" + nonlocal gate_twirl_site_idx + if twirl_site_schedule != "before_two_qubit_gate": + return + for control_q, target_q, _label in cx_ops: + emit_gate_local_twirl_site( + target_ops, + gate_twirl_site_idx, + control_q, + target_q, + ) + gate_twirl_site_idx += 1 + # Get CNOT schedule cnot_rounds = compute_cnot_schedule(patch) + if interaction_basis == "szz": + if twirl is not None: + msg = "interaction_basis='szz' twirl integration is staged later; omit twirl for Stage 1" + raise ValueError(msg) + return ( + _build_surface_code_circuit_szz( + patch, + num_rounds, + basis, + allocation, + cnot_rounds, + _szz_residual_plan_for_check_plan(patch, resolved_plan), + ancilla_schedule, + resolved_clifford_frame, + ), + allocation, + ) + ops: list[SurfaceCircuitStep] = [] # ========================================================================= @@ -237,6 +1066,138 @@ def z_anc_q(stab_idx: int) -> int: ops.append(SurfaceCircuitStep(OpType.TICK)) + # ========================================================================= + # init_{basis}_basis syndrome establishment + # ========================================================================= + # Data prep fixes only the stabilizers matching the memory basis. Measure + # the complementary stabilizer family once to establish its random signs; + # this is logical state prep and is intentionally not counted in + # `num_rounds`. + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + ops.append( + SurfaceCircuitStep( + OpType.COMMENT, + label=f"init_{init_stabilizer_type.lower()}_syndrome", + ), + ) + if effective_ancilla_budget == total_ancilla: + init_stabilizers = geom.x_stabilizers if init_stabilizer_type == "X" else geom.z_stabilizers + init_anc_q = x_anc_q if init_stabilizer_type == "X" else z_anc_q + + ops.extend( + SurfaceCircuitStep( + OpType.ALLOC, + [init_anc_q(s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in init_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cx_round in enumerate(cnot_rounds): + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] + for stab_type, stab_idx, data_idx in cx_round: + if stab_type != init_stabilizer_type: + continue + if stab_type == "X": + cx_ops.append((x_anc_q(stab_idx), data_q(data_idx), f"X{stab_idx}")) + else: + cx_ops.append((data_q(data_idx), z_anc_q(stab_idx), f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in init_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + init_label_prefix = "sx" if init_stabilizer_type == "X" else "sz" + ops.extend( + SurfaceCircuitStep( + OpType.MEASURE, + [init_anc_q(s.index)], + f"{init_label_prefix}{s.index}", + ) + for s in init_stabilizers + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + else: + stabilizer_batches = _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ) + for batch in stabilizer_batches: + init_batch = [(stab_type, stab_idx) for stab_type, stab_idx in batch if stab_type == init_stabilizer_type] + if not init_batch: + continue + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Prepare ancillas")) + batch_ancillas = { + (stab_type, stab_idx): x_anc_q(stab_idx) if stab_type == "X" else z_anc_q(stab_idx) + for stab_type, stab_idx in init_batch + } + + for stab_type, stab_idx in init_batch: + ops.append( + SurfaceCircuitStep( + OpType.ALLOC, + [batch_ancillas[(stab_type, stab_idx)]], + f"a{stab_type.lower()}{stab_idx}", + ), + ) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend( + SurfaceCircuitStep(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") + for _stab_type, stab_idx in init_batch + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cx_round in enumerate(cnot_rounds): + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] + for stab_type, stab_idx, data_idx in cx_round: + ancilla_q = batch_ancillas.get((stab_type, stab_idx)) + if ancilla_q is None: + continue + if stab_type == "X": + cx_ops.append((ancilla_q, data_q(data_idx), f"X{stab_idx}")) + else: + cx_ops.append((data_q(data_idx), ancilla_q, f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend( + SurfaceCircuitStep(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") + for _stab_type, stab_idx in init_batch + ) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + for stab_type, stab_idx in init_batch: + measure_label = f"sx{stab_idx}" if stab_type == "X" else f"sz{stab_idx}" + ops.append( + SurfaceCircuitStep( + OpType.MEASURE, + [batch_ancillas[(stab_type, stab_idx)]], + measure_label, + ), + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + # ========================================================================= # syndrome_extraction (called num_rounds times) # ========================================================================= @@ -255,23 +1216,14 @@ def z_anc_q(stab_idx: int) -> int: for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [x_anc_q(stab_idx), data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((x_anc_q(stab_idx), data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), z_anc_q(stab_idx)], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), z_anc_q(stab_idx), f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) ops.append(SurfaceCircuitStep(OpType.TICK)) ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) @@ -287,7 +1239,11 @@ def z_anc_q(stab_idx: int) -> int: ops.append(SurfaceCircuitStep(OpType.TICK)) else: - stabilizer_batches = _batched_stabilizers(patch, effective_ancilla_budget) + stabilizer_batches = _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ) for batch in stabilizer_batches: ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Prepare ancillas")) batch_ancillas = { @@ -316,26 +1272,19 @@ def z_anc_q(stab_idx: int) -> int: for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: ancilla_q = batch_ancillas.get((stab_type, stab_idx)) if ancilla_q is None: continue if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [ancilla_q, data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((ancilla_q, data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), ancilla_q], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), ancilla_q, f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend( + SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops + ) ops.append(SurfaceCircuitStep(OpType.TICK)) if x_stabilizers_in_batch: @@ -358,6 +1307,9 @@ def z_anc_q(stab_idx: int) -> int: ops.append(SurfaceCircuitStep(OpType.TICK)) + if twirl_site_schedule == "between_rounds" and rnd < num_rounds - 1: + emit_between_round_twirl_site(ops, rnd) + # ========================================================================= # measure_z_basis / measure_x_basis # ========================================================================= @@ -373,6 +1325,301 @@ def z_anc_q(stab_idx: int) -> int: return ops, allocation +def _build_surface_code_circuit_szz( + patch: SurfacePatch, + num_rounds: int, + basis: str, + allocation: QubitAllocation, + cnot_rounds: list[list[tuple[str, int, int]]], + residual_plan: SzzResidualPlan, + ancilla_schedule: str, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None = None, +) -> list[SurfaceCircuitStep]: + """Build the abstract SZZ/SZZdg surface-memory template.""" + geom = patch.geometry + num_data = geom.num_data + check_by_key: dict[tuple[str, int], ResolvedPauliCheck] = {} + if resolved_clifford_frame is not None: + check_by_key.update({("X", check.stabilizer_index): check for check in resolved_clifford_frame.x_checks}) + check_by_key.update({("Z", check.stabilizer_index): check for check in resolved_clifford_frame.z_checks}) + sign_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs + } + gate_name_by_type = { + OpType.SX: "SX", + OpType.SXDG: "SXDG", + OpType.SY: "SY", + OpType.SYDG: "SYDG", + OpType.SZ: "SZ", + OpType.SZDG: "SZDG", + } + + def data_q(i: int) -> int: + return allocation.data_qubits[i] + + def x_anc_q(stab_idx: int) -> int: + return allocation.x_ancilla_qubits[stab_idx] + + def z_anc_q(stab_idx: int) -> int: + return allocation.z_ancilla_qubits[stab_idx] + + def anc_q(stabilizer_type: str, stab_idx: int) -> int: + return x_anc_q(stab_idx) if stabilizer_type == "X" else z_anc_q(stab_idx) + + def physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_idx: int) -> PauliAxis: + if resolved_clifford_frame is None: + return "X" if stabilizer_type == "X" else "Z" + check = check_by_key[(stabilizer_type, stab_idx)] + try: + offset = check.data_qubits.index(data_idx) + except ValueError as exc: + msg = f"data qubit {data_idx} is not in resolved check {stabilizer_type}{stab_idx}" + raise ValueError(msg) from exc + return check.paulis[offset].axis + + def physical_axis_for_memory_data(data_idx: int) -> PauliAxis: + return _szz_memory_physical_axis_for_data( + basis, + resolved_clifford_frame, + data_idx, + ) + + def append_axis_rotation_to_z( + target_ops: list[SurfaceCircuitStep], + axis: PauliAxis, + qubit: int, + label_prefix: str, + ) -> None: + gate = { + "X": OpType.H, + "Y": OpType.SXDG, + "Z": None, + }[axis] + if gate is not None: + target_ops.append(SurfaceCircuitStep(gate, [qubit], f"{label_prefix}:to_z")) + + def append_axis_rotation_from_z( + target_ops: list[SurfaceCircuitStep], + axis: PauliAxis, + qubit: int, + label_prefix: str, + ) -> None: + gate = { + "X": OpType.H, + "Y": OpType.SX, + "Z": None, + }[axis] + if gate is not None: + target_ops.append(SurfaceCircuitStep(gate, [qubit], f"{label_prefix}:from_z")) + + def szz_touch_compensation(axis: PauliAxis, sign: int) -> OpType: + return { + ("X", 1): OpType.SXDG, + ("X", -1): OpType.SX, + ("Y", 1): OpType.SYDG, + ("Y", -1): OpType.SY, + ("Z", 1): OpType.SZDG, + ("Z", -1): OpType.SZ, + }[(axis, sign)] + + def stabilizer_batches_for(selected_type: str | None = None) -> list[list[tuple[str, int]]]: + """Return the same ancilla-reuse batches used by the CX template.""" + total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) + allocation_ancilla_count = len(set(allocation.x_ancilla_qubits + allocation.z_ancilla_qubits)) + if allocation_ancilla_count >= total_ancilla: + batch = [("X", s.index) for s in geom.x_stabilizers] + batch.extend(("Z", s.index) for s in geom.z_stabilizers) + batches = [batch] + else: + batches = _batched_stabilizers( + patch, + allocation_ancilla_count, + ancilla_schedule=ancilla_schedule, + ) + + if selected_type is None: + return batches + return [ + [(stab_type, stab_idx) for stab_type, stab_idx in batch if stab_type == selected_type] + for batch in batches + if any(stab_type == selected_type for stab_type, _stab_idx in batch) + ] + + def append_prepare_szz_ancillas(target_ops: list[SurfaceCircuitStep], batch: list[tuple[str, int]]) -> None: + target_ops.extend( + SurfaceCircuitStep( + OpType.ALLOC, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + + def append_measure_szz_ancillas(target_ops: list[SurfaceCircuitStep], batch: list[tuple[str, int]]) -> None: + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.MEASURE, + [anc_q(stab_type, stab_idx)], + f"{'sx' if stab_type == 'X' else 'sz'}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + + def append_szz_layer( + target_ops: list[SurfaceCircuitStep], + rnd_idx: int, + layer_gates: list[tuple[str, int, int]], + ) -> None: + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"SZZ round {rnd_idx + 1}")) + for stab_type, stab_idx, data_idx in layer_gates: + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + append_axis_rotation_to_z( + target_ops, + axis, + data_q(data_idx), + f"szz_{axis.lower()}_touch_pre:{stab_type}{stab_idx}:d{data_idx}", + ) + + for stab_type, stab_idx, data_idx in layer_gates: + sign = sign_by_touch[(stab_type, stab_idx, data_idx)] + op_type = OpType.SZZ if sign > 0 else OpType.SZZDG + target_ops.append( + SurfaceCircuitStep( + op_type, + [anc_q(stab_type, stab_idx), data_q(data_idx)], + f"{stab_type}{stab_idx}", + ), + ) + + for stab_type, stab_idx, data_idx in layer_gates: + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + append_axis_rotation_from_z( + target_ops, + axis, + data_q(data_idx), + f"szz_{axis.lower()}_touch_post:{stab_type}{stab_idx}:d{data_idx}", + ) + + for stab_type, stab_idx, data_idx in layer_gates: + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + sign = sign_by_touch[(stab_type, stab_idx, data_idx)] + compensation = szz_touch_compensation(axis, sign) + target_ops.append( + SurfaceCircuitStep( + compensation, + [data_q(data_idx)], + f"szz_touch_comp:{gate_name_by_type[compensation]}:{axis}:{stab_type}{stab_idx}:d{data_idx}", + ), + ) + + ops: list[SurfaceCircuitStep] = [] + + # ========================================================================= + # prep_z_basis / prep_x_basis + # ========================================================================= + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) + ops.extend(SurfaceCircuitStep(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) + for i in range(num_data): + basis_axis = physical_axis_for_memory_data(i) + append_axis_rotation_to_z( + ops, + basis_axis, + data_q(i), + f"prep_{basis_axis.lower()}_basis_d{i}", + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # init_{basis}_basis syndrome establishment + # ========================================================================= + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + ops.append( + SurfaceCircuitStep( + OpType.COMMENT, + label=f"init_{init_stabilizer_type.lower()}_syndrome", + ), + ) + for init_batch in stabilizer_batches_for(init_stabilizer_type): + append_prepare_szz_ancillas(ops, init_batch) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + init_keys = set(init_batch) + for rnd_idx, cnot_round in enumerate(cnot_rounds): + layer_gates = [ + (stab_type, stab_idx, data_idx) + for stab_type, stab_idx, data_idx in cnot_round + if (stab_type, stab_idx) in init_keys + ] + append_szz_layer(ops, rnd_idx, layer_gates) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + append_measure_szz_ancillas(ops, init_batch) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # syndrome_extraction + # ========================================================================= + stabilizer_batches = stabilizer_batches_for() + for rnd in range(num_rounds): + ops.append( + SurfaceCircuitStep(OpType.COMMENT, label=f"syndrome_extraction round {rnd + 1}"), + ) + + for batch in stabilizer_batches: + append_prepare_szz_ancillas(ops, batch) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + batch_keys = set(batch) + for rnd_idx, cnot_round in enumerate(cnot_rounds): + layer_gates = [ + (stab_type, stab_idx, data_idx) + for stab_type, stab_idx, data_idx in cnot_round + if (stab_type, stab_idx) in batch_keys + ] + append_szz_layer(ops, rnd_idx, layer_gates) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + append_measure_szz_ancillas(ops, batch) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # measure_z_basis / measure_x_basis + # ========================================================================= + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) + for i in range(num_data): + basis_axis = physical_axis_for_memory_data(i) + append_axis_rotation_from_z( + ops, + basis_axis, + data_q(i), + f"measure_{basis_axis.lower()}_basis_d{i}", + ) + ops.extend(SurfaceCircuitStep(OpType.MEASURE, [data_q(i)], f"final[{i}]") for i in range(num_data)) + + _analyze_szz_forward_flow(ops) + return ops + + def classify_stabilizer_boundary(stab_type: str, data_qubits: tuple[int, ...], d: int, dz: int | None = None) -> str: """Public wrapper for classifying a boundary stabilizer.""" from pecos.qec.surface.schedule import _classify_boundary @@ -534,12 +1781,44 @@ def render( if self.p1 > 0: lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + elif op.op_type == OpType.SX: + lines.append(f"SQRT_X {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SXDG: + lines.append(f"SQRT_X_DAG {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SZ: + lines.append(f"S {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SZDG: + lines.append(f"S_DAG {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + elif op.op_type == OpType.CX: c, t = op.qubits lines.append(f"CX {c} {t}") if self.p2 > 0: lines.append(f"DEPOLARIZE2({self.p2}) {c} {t}") + elif op.op_type == OpType.SZZ: + a, b = op.qubits + lines.append(f"SQRT_ZZ {a} {b}") + if self.p2 > 0: + lines.append(f"DEPOLARIZE2({self.p2}) {a} {b}") + + elif op.op_type == OpType.SZZDG: + a, b = op.qubits + lines.append(f"SQRT_ZZ_DAG {a} {b}") + if self.p2 > 0: + lines.append(f"DEPOLARIZE2({self.p2}) {a} {b}") + elif op.op_type == OpType.MEASURE: q = op.qubits[0] if self.p_meas > 0: @@ -561,15 +1840,23 @@ def render( elif op.op_type == OpType.TICK: lines.append("TICK") + elif op.op_type == OpType.TRACKED_PAULI: + msg = ( + "StimRenderer does not yet handle OpType.TRACKED_PAULI; " + "use TickCircuit / PauliFrameLookup path for twirled DEMs" + ) + raise NotImplementedError(msg) + # Add detector annotations if requested if self.add_detectors: lines.append("") lines.append("# Detectors") - # Determine which stabilizer types are deterministic in round 0 - # Z-basis: Z stabilizers are deterministic (eigenvalue +1 on |0>) - # X-basis: X stabilizers are deterministic (eigenvalue +1 on |+>) + # Data prep fixes stabilizers matching the memory basis. The + # complementary family is random but has an explicit init + # measurement, which round 0 compares against. deterministic_type_round0 = "Z" if basis.upper() == "Z" else "X" + init_baseline_type = "X" if basis.upper() == "Z" else "Z" # Syndrome detectors for X stabilizers for rnd in range(num_rounds): @@ -580,12 +1867,16 @@ def render( curr_offset = meas_count - curr_idx if rnd == 0: - # Only X stabilizers have deterministic round-0 detectors in X-basis - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_idx = stab_meas_record[("X", s.index, -1)] + init_offset = meas_count - init_idx + lines.append( + f"DETECTOR({s.index}, 0, {rnd}) rec[{-curr_offset}] rec[{-init_offset}]", + ) + elif deterministic_type_round0 == "X": lines.append( f"DETECTOR({s.index}, 0, {rnd}) rec[{-curr_offset}]", ) - # In Z-basis, X stabilizers are random in round 0, skip single-record detector else: # Compare consecutive rounds (always valid) prev_idx = stab_meas_record[("X", s.index, rnd - 1)] @@ -604,12 +1895,16 @@ def render( det_x = num_x_anc + s.index if rnd == 0: - # Only Z stabilizers have deterministic round-0 detectors in Z-basis - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_idx = stab_meas_record[("Z", s.index, -1)] + init_offset = meas_count - init_idx + lines.append( + f"DETECTOR({det_x}, 1, {rnd}) rec[{-curr_offset}] rec[{-init_offset}]", + ) + elif deterministic_type_round0 == "Z": lines.append( f"DETECTOR({det_x}, 1, {rnd}) rec[{-curr_offset}]", ) - # In X-basis, Z stabilizers are random in round 0, skip single-record detector else: # Compare consecutive rounds (always valid) prev_idx = stab_meas_record[("Z", s.index, rnd - 1)] @@ -631,13 +1926,15 @@ def render( for s in stabilizers: data_rec_offsets = [meas_count - (final_meas_start + dq) for dq in s.data_qubits] - last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] - syn_offset = meas_count - last_syn_idx - rec_str = " ".join(f"rec[{-off}]" for off in data_rec_offsets) + record_offsets = [*data_rec_offsets] + if num_rounds > 0: + last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] + record_offsets.append(meas_count - last_syn_idx) + rec_str = " ".join(f"rec[{-off}]" for off in record_offsets) det_x = s.index if stab_type == "X" else num_x_anc + s.index det_y = 0 if stab_type == "X" else 1 lines.append( - f"DETECTOR({det_x}, {det_y}, {num_rounds}) {rec_str} rec[{-syn_offset}]", + f"DETECTOR({det_x}, {det_y}, {num_rounds}) {rec_str}", ) # Logical observable @@ -662,6 +1959,8 @@ def render( patch: SurfacePatch, _num_rounds: int, _basis: str, + *, + interaction_basis: str = "cx", ) -> str: """Render to Guppy source code. @@ -676,7 +1975,7 @@ def render( from pecos.guppy.surface import generate_guppy_source # Use the canonical Guppy generator to ensure identical output - return generate_guppy_source(patch) + return generate_guppy_source(patch, interaction_basis=interaction_basis) class DagCircuitRenderer(CircuitRenderer): @@ -691,7 +1990,7 @@ def render( _basis: str, ) -> DagCircuit: """Render to PECOS DagCircuit.""" - from pecos_rslib import DagCircuit + from pecos_rslib import DagCircuit, Gate, GateType circuit = DagCircuit() allocated: set[int] = set() @@ -715,6 +2014,18 @@ def render( elif op.op_type == OpType.H: circuit.h([op.qubits[0]]) + elif op.op_type == OpType.SX: + circuit.add_gate(Gate(GateType.SX, qubits=[op.qubits[0]])) + + elif op.op_type == OpType.SXDG: + circuit.add_gate(Gate(GateType.SXdg, qubits=[op.qubits[0]])) + + elif op.op_type == OpType.SZ: + circuit.sz([op.qubits[0]]) + + elif op.op_type == OpType.SZDG: + circuit.szdg([op.qubits[0]]) + elif op.op_type == OpType.X: circuit.x([op.qubits[0]]) @@ -724,12 +2035,30 @@ def render( elif op.op_type == OpType.CX: circuit.cx([(op.qubits[0], op.qubits[1])]) + elif op.op_type == OpType.SZZ: + circuit.szz([(op.qubits[0], op.qubits[1])]) + + elif op.op_type == OpType.SZZDG: + circuit.szzdg([(op.qubits[0], op.qubits[1])]) + elif op.op_type == OpType.MEASURE: - circuit.mz([op.qubits[0]]) + q = op.qubits[0] + if op.label.startswith(("sx", "sz")): + circuit.mz_free([q]) + allocated.discard(q) + else: + circuit.mz([q]) elif op.op_type == OpType.TICK: pass # DagCircuit doesn't have explicit ticks + elif op.op_type == OpType.TRACKED_PAULI: + msg = ( + "DagCircuitRenderer does not yet handle OpType.TRACKED_PAULI; " + "use TickCircuit / PauliFrameLookup path for twirled DEMs" + ) + raise NotImplementedError(msg) + return circuit @@ -747,13 +2076,20 @@ class TickCircuitRenderer(CircuitRenderer): are stored as circuit metadata and preserved when converting to DagCircuit. """ - def __init__(self, *, add_detectors: bool = True) -> None: + def __init__( + self, + *, + add_detectors: bool = True, + add_typed_annotations: bool = True, + ) -> None: """Initialize TickCircuit renderer. Args: - add_detectors: Whether to add detector annotations as metadata + add_detectors: Whether to add detector/observable metadata. + add_typed_annotations: Whether to also add typed Pauli annotations. """ self.add_detectors = add_detectors + self.add_typed_annotations = add_typed_annotations def render( self, @@ -826,9 +2162,9 @@ def get_stabilizer_from_label(label: str) -> str: return f"Z{int(label[2:])}" return "" - # Helper to get stabilizer name for a CX gate - def get_cx_stabilizer(control: int, target: int, label: str = "") -> str: - """Get stabilizer name for a CX gate (e.g., 'X0', 'Z2').""" + # Helper to get stabilizer name for a two-qubit check interaction. + def get_check_stabilizer(control: int, target: int, label: str = "") -> str: + """Get stabilizer name for a two-qubit check gate (e.g., 'X0', 'Z2').""" from_label = get_stabilizer_from_label(label) if from_label: return from_label @@ -869,11 +2205,18 @@ def get_ancilla_gate_metadata(qubit: int, label: str = "") -> dict[str, object]: metadata["ancilla_qubit"] = qubit return metadata - def get_cx_gate_metadata(control: int, target: int, label: str = "") -> dict[str, object]: - stab_label = get_cx_stabilizer(control, target, label) + def get_two_qubit_check_metadata( + control: int, + target: int, + label: str = "", + *, + gate_kind: str, + ) -> dict[str, object]: + stab_label = get_check_stabilizer(control, target, label) if not stab_label: return {} metadata = get_stabilizer_metadata(stab_label) + metadata["interaction_gate"] = gate_kind ancilla_qubit = next( (q for q in (control, target) if q in stabilizer_by_ancilla_qubit), None, @@ -922,6 +2265,15 @@ def mark_qubits_used(qubits: list[int]) -> None: """Mark qubits as used in current tick.""" qubits_in_current_tick.update(qubits) + def is_syndrome_context(phase: str, round_index: int) -> bool: + """Return whether the current context belongs to syndrome extraction.""" + if round_index >= 0 or phase.startswith("init_syndrome"): + return True + return round_index == -1 and ( + phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} + or phase.startswith(("cx_round_", "szz_round_")) + ) + def gate_metadata(meta: dict | None = None) -> dict: """Build metadata for the current gate context. @@ -931,7 +2283,7 @@ def gate_metadata(meta: dict | None = None) -> dict: context: dict[str, object] = { "phase": current_phase, } - if current_round >= 0: + if is_syndrome_context(current_phase, current_round): context["syndrome_round"] = current_round if current_cx_round > 0: context["cx_round"] = current_cx_round @@ -958,14 +2310,25 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non current_round = int(op.label.split()[-1]) - 1 current_phase = "syndrome_prep" current_cx_round = 0 + elif "init_" in op.label and "syndrome" in op.label: + current_round = -1 + current_phase = "init_syndrome_prep" + current_cx_round = 0 elif "Prepare ancillas" in op.label: - current_phase = "syndrome_prep" + current_phase = "init_syndrome_prep" if current_round < 0 else "syndrome_prep" current_cx_round = 0 - elif "Hadamard on X ancillas" in op.label: - current_phase = "syndrome_h_pre" if current_phase == "syndrome_prep" else "syndrome_h_post" + elif "Hadamard on X ancillas" in op.label or "Hadamard on SZZ ancillas" in op.label: + current_phase = ( + "syndrome_h_pre" + if current_phase in {"syndrome_prep", "init_syndrome_prep"} + else "syndrome_h_post" + ) elif "CX round" in op.label: current_cx_round = int(op.label.split()[-1]) current_phase = f"cx_round_{current_cx_round}" + elif "SZZ round" in op.label: + current_cx_round = int(op.label.split()[-1]) + current_phase = f"szz_round_{current_cx_round}" elif "Measure ancillas" in op.label: current_phase = "measure_ancilla" elif "prep_z_basis" in op.label or "prep_x_basis" in op.label: @@ -1006,6 +2369,78 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta["label"] = op.label apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.F: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).f([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.FDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).fdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SX: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sx([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SXDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sxdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SY: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sy([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SYDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sydg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZ: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sz([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).szdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.X: q = op.qubits[0] tick = get_tick_for_qubits([q]).x([q]) @@ -1022,20 +2457,44 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta = get_ancilla_gate_metadata(q, op.label) if op.label: meta["label"] = op.label + if op.label.startswith("szz_virtual_prefix:"): + meta[PHYSICAL_DURATION_META_KEY] = 0.0 apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.CX: qubits = op.qubits tick = get_tick_for_qubits(qubits).cx([(qubits[0], qubits[1])]) mark_qubits_used(qubits) - meta = get_cx_gate_metadata(qubits[0], qubits[1], op.label) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="CX") + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZZ: + qubits = op.qubits + tick = get_tick_for_qubits(qubits).szz([(qubits[0], qubits[1])]) + mark_qubits_used(qubits) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="SZZ") + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZZDG: + qubits = op.qubits + tick = get_tick_for_qubits(qubits).szzdg([(qubits[0], qubits[1])]) + mark_qubits_used(qubits) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="SZZdg") if op.label: meta["label"] = op.label apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.MEASURE: q = op.qubits[0] - meas_refs = get_tick_for_qubits([q]).mz([q]) + if op.label.startswith(("sx", "sz")): + meas_refs = get_tick_for_qubits([q]).mz_free([q]) + allocated.discard(q) + else: + meas_refs = get_tick_for_qubits([q]).mz([q]) mark_qubits_used([q]) # Label helps identify measurement (e.g., "sx0", "sz0", "final[0]") meta = get_ancilla_gate_metadata(q, op.label) @@ -1063,12 +2522,34 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non current_tick_handle = None qubits_in_current_tick = set() + elif op.op_type == OpType.TRACKED_PAULI: + from pecos_rslib import PauliString + + q = op.qubits[0] + kind, sep, site_suffix = op.label.partition("@") + pauli_ctor = { + "X": PauliString.X, + "Y": PauliString.Y, + "Z": PauliString.Z, + }.get(kind) + if pauli_ctor is None or sep != "@" or not site_suffix.startswith(("s", "g")): + msg = ( + "OpType.TRACKED_PAULI requires label of the form " + f"'{{X|Y|Z}}@s' or " + f"'{{X|Y|Z}}@go', got {op.label!r}" + ) + raise ValueError(msg) + circuit.tracked_pauli( + pauli_ctor(q), + label=f"twirl_{site_suffix}_q{q}_{kind}", + ) + # Apply tick-level metadata in place. Gate metadata is attached as each # gate is emitted so batching decisions can account for it immediately. for tick_idx, tick_meta in all_tick_metadata.items(): # Set tick-level metadata circuit.set_tick_meta(tick_idx, "phase", tick_meta["phase"]) - if tick_meta["round"] >= 0: + if is_syndrome_context(str(tick_meta["phase"]), int(tick_meta["round"])): circuit.set_tick_meta(tick_idx, "syndrome_round", tick_meta["round"]) if tick_meta["cx_round"] > 0: circuit.set_tick_meta(tick_idx, "cx_round", tick_meta["cx_round"]) @@ -1078,6 +2559,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non geom = patch.geometry num_x_anc = len(geom.x_stabilizers) deterministic_type_round0 = "Z" if basis.upper() == "Z" else "X" + init_baseline_type = "X" if basis.upper() == "Z" else "Z" detectors = [] detector_id = 0 @@ -1091,7 +2573,18 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non curr_offset = meas_count - curr_idx if rnd == 0: - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_idx = stab_meas_record[("X", s.index, -1)] + init_offset = meas_count - init_idx + detectors.append( + { + "id": detector_id, + "coords": [s.index, 0, rnd], + "records": [-curr_offset, -init_offset], + }, + ) + detector_id += 1 + elif deterministic_type_round0 == "X": detectors.append( { "id": detector_id, @@ -1122,7 +2615,18 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non det_x = num_x_anc + s.index if rnd == 0: - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_idx = stab_meas_record[("Z", s.index, -1)] + init_offset = meas_count - init_idx + detectors.append( + { + "id": detector_id, + "coords": [det_x, 1, rnd], + "records": [-curr_offset, -init_offset], + }, + ) + detector_id += 1 + elif deterministic_type_round0 == "Z": detectors.append( { "id": detector_id, @@ -1155,15 +2659,17 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non for s in stabilizers: data_rec_offsets = [-(meas_count - (final_meas_start + dq)) for dq in s.data_qubits] - last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] - syn_offset = -(meas_count - last_syn_idx) + records = [*data_rec_offsets] + if num_rounds > 0: + last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] + records.append(-(meas_count - last_syn_idx)) det_x = s.index if stab_type == "X" else num_x_anc + s.index det_y = 0 if stab_type == "X" else 1 detectors.append( { "id": detector_id, "coords": [det_x, det_y, num_rounds], - "records": [*data_rec_offsets, syn_offset], + "records": records, }, ) detector_id += 1 @@ -1183,16 +2689,23 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non circuit.set_meta("num_measurements", str(meas_count)) circuit.set_meta("num_detectors", str(len(detectors))) - # Also add typed PauliAnnotation annotations (new path) - self._add_typed_annotations( - circuit, - geom, - num_rounds, - basis, - stab_meas_refs, - final_meas_refs_by_qubit, - deterministic_type_round0, - ) + # Also add typed PauliAnnotation annotations (new path) when the + # caller wants direct Pauli annotations in addition to legacy + # measurement-record metadata. Native surface DEM construction uses + # the JSON metadata below and disables these annotations to avoid + # mixing two independent observable sources in the same influence + # map. + if self.add_typed_annotations: + self._add_typed_annotations( + circuit, + geom, + num_rounds, + basis, + stab_meas_refs, + final_meas_refs_by_qubit, + deterministic_type_round0, + init_baseline_type, + ) circuit.set_meta("basis", basis.upper()) circuit.set_meta("ancilla_budget", str(allocation.total - len(allocation.data_qubits))) @@ -1207,6 +2720,7 @@ def _add_typed_annotations( stab_meas_refs: dict, final_meas_refs_by_qubit: dict, deterministic_type_round0: str, + init_baseline_type: str, ) -> None: """Add typed PauliAnnotation detectors and observables to the circuit. @@ -1220,7 +2734,10 @@ def _add_typed_annotations( if curr_refs is None: continue if rnd == 0: - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_refs = stab_meas_refs.get(("X", s.index, -1), []) + circuit.detector(init_refs + curr_refs, label=f"Sx{s.index}_r{rnd}") + elif deterministic_type_round0 == "X": circuit.detector(curr_refs, label=f"Sx{s.index}_r{rnd}") else: prev_refs = stab_meas_refs.get(("X", s.index, rnd - 1), []) @@ -1233,7 +2750,10 @@ def _add_typed_annotations( if curr_refs is None: continue if rnd == 0: - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_refs = stab_meas_refs.get(("Z", s.index, -1), []) + circuit.detector(init_refs + curr_refs, label=f"Sz{s.index}_r{rnd}") + elif deterministic_type_round0 == "Z": circuit.detector(curr_refs, label=f"Sz{s.index}_r{rnd}") else: prev_refs = stab_meas_refs.get(("Z", s.index, rnd - 1), []) @@ -1284,10 +2804,13 @@ def generate_stim_from_patch( basis: str = "Z", *, ancilla_budget: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, p_prep: float = 0.0, + add_detectors: bool = True, ) -> str: """Generate Stim circuit from SurfacePatch. @@ -1296,16 +2819,26 @@ def generate_stim_from_patch( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. p1: Single-qubit error rate p2: Two-qubit error rate p_meas: Measurement error rate p_prep: Initialization error rate + add_detectors: Whether to add detector/observable annotations. Returns: Stim circuit string """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) - renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, add_detectors=add_detectors) return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -1313,6 +2846,10 @@ def generate_guppy_from_patch( patch: SurfacePatch, _num_rounds: int = 1, _basis: str = "Z", + *, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate Guppy code from SurfacePatch. @@ -1328,13 +2865,22 @@ def generate_guppy_from_patch( patch: Surface code patch _num_rounds: Unused (factory functions accept this at runtime) _basis: Unused (module includes both Z and X basis functions) + interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Guppy source code string (full module) """ from pecos.guppy.surface import generate_guppy_source - return generate_guppy_source(patch) + return generate_guppy_source( + patch, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + ) def generate_dag_circuit_from_patch( @@ -1342,6 +2888,9 @@ def generate_dag_circuit_from_patch( num_rounds: int, basis: str = "Z", ancilla_budget: int | None = None, + *, + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> DagCircuit: """Generate PECOS DagCircuit from SurfacePatch. @@ -1350,11 +2899,20 @@ def generate_dag_circuit_from_patch( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: PECOS DagCircuit instance """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + interaction_basis=interaction_basis, + check_plan=check_plan, + ) renderer = DagCircuitRenderer() return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -1365,7 +2923,13 @@ def generate_tick_circuit_from_patch( basis: str = "Z", *, add_detectors: bool = True, + add_typed_annotations: bool = True, ancilla_budget: int | None = None, + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -1386,17 +2950,158 @@ def generate_tick_circuit_from_patch( patch: Surface code patch num_rounds: Number of syndrome rounds basis: 'Z' or 'X' - add_detectors: Whether to add detector annotations as metadata + add_detectors: Whether to add detector/observable metadata. + add_typed_annotations: Whether to also add typed Pauli annotations. ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Optional Pauli-frame randomization layout. When supplied, + tracked-Pauli annotations are emitted even if + ``add_typed_annotations`` is false; that flag controls detector + and observable typed annotations, not the twirl lookup channel. + interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. + szz_physical_prefixes: If true, lower the abstract SZZ single-qubit + scaffold into physical prefix pulses for native DEM analysis. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ generation. Currently supports global uniform-axis + frames. Returns: PECOS TickCircuit instance """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) - renderer = TickCircuitRenderer(add_detectors=add_detectors) + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="abstract surface TickCircuit generation", + ) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) + if szz_physical_prefixes and interaction_basis != "szz": + msg = "szz_physical_prefixes=True requires interaction_basis='szz'" + raise ValueError(msg) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, + ) + if szz_physical_prefixes: + ops = _lower_szz_forward_flow_ops(ops) + renderer = TickCircuitRenderer( + add_detectors=add_detectors, + add_typed_annotations=add_typed_annotations, + ) return renderer.render(ops, allocation, patch, num_rounds, basis) +def normalize_traced_qis_tick_circuit( + tick_circuit: object, + *, + context: str = "traced-QIS DEM construction", + simplify_single_qubit_clifford_chains: bool = True, +) -> None: + """Normalize a traced-QIS TickCircuit before DEM/DAG analysis. + + Selene/QIS traces may contain parameterized Clifford rotations such as + ``RZZ(pi/2)``. Fault analysis and replacement-branch noise models operate + on the named Clifford gates (``SZZ`` / ``SZZdg``), so callers should run + this helper at every traced-QIS boundary before converting to a DAG. + """ + _call_required_tick_circuit_method(tick_circuit, "lower_clifford_rotations", context) + if simplify_single_qubit_clifford_chains: + _call_required_tick_circuit_method( + tick_circuit, + "simplify_single_qubit_clifford_chains", + context, + ) + _call_required_tick_circuit_method(tick_circuit, "assign_missing_meas_ids", context) + assert_traced_qis_tick_circuit_dem_ready(tick_circuit, context=context) + + +def assert_traced_qis_tick_circuit_dem_ready( + tick_circuit: object, + *, + context: str = "traced-QIS DEM construction", +) -> None: + """Fail loudly if raw traced-QIS rotations survived normalization.""" + offenders = _raw_traced_qis_rzz_gates(tick_circuit, context=context) + if not offenders: + return + + preview = "; ".join(offenders[:5]) + suffix = f"; ... {len(offenders) - 5} more" if len(offenders) > 5 else "" + msg = ( + f"{context}: traced-QIS circuit still contains raw RZZ gates after Clifford " + "normalization. DEM/DAG analysis expects Clifford RZZ(pi/2) and " + "RZZ(-pi/2) gates to be lowered to SZZ/SZZdg before noise attachment " + "and fault propagation. Call normalize_traced_qis_tick_circuit(...) " + "before to_dag_circuit(), or extend lower_clifford_rotations() for the " + f"runtime-emitted angle. First offending gates: {preview}{suffix}" + ) + raise ValueError(msg) + + +def _call_required_tick_circuit_method(tick_circuit: object, method_name: str, context: str) -> None: + method = getattr(tick_circuit, method_name, None) + if not callable(method): + msg = f"{context}: expected a TickCircuit with callable {method_name}()." + raise TypeError(msg) + method() + + +def _raw_traced_qis_rzz_gates(tick_circuit: object, *, context: str) -> list[str]: + try: + num_ticks = int(tick_circuit.num_ticks()) # type: ignore[attr-defined] + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with num_ticks() before DEM/DAG analysis." + raise TypeError(msg) from exc + + offenders: list[str] = [] + for tick_index in range(num_ticks): + try: + tick = tick_circuit.get_tick(tick_index) # type: ignore[attr-defined] + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with get_tick() before DEM/DAG analysis." + raise TypeError(msg) from exc + try: + gate_batches = tick.gate_batches() + except AttributeError as exc: + msg = f"{context}: expected TickCircuit ticks with gate_batches() before DEM/DAG analysis." + raise TypeError(msg) from exc + for gate_index, gate in enumerate(gate_batches): + if _gate_type_name(gate) != "RZZ": + continue + qubits = [int(q) for q in getattr(gate, "qubits", [])] + offenders.append( + f"tick={tick_index} gate={gate_index} qubits={qubits} angles={_gate_angles_for_message(gate)}", + ) + return offenders + + +def _gate_type_name(gate: object) -> str: + gate_type = getattr(gate, "gate_type", "") + return str(getattr(gate_type, "name", str(gate_type).rsplit(".", maxsplit=1)[-1])) + + +def _format_gate_angle(angle: object) -> str: + try: + return repr(float(angle)) + except (TypeError, ValueError): + return repr(angle) + + +def _gate_angles_for_message(gate: object) -> list[str]: + angles = getattr(gate, "angles", None) + if angles is None: + angles = getattr(gate, "params", []) + return [_format_gate_angle(angle) for angle in angles] + + def get_detector_descriptors_from_tick_circuit( tick_circuit: TickCircuit, patch: SurfacePatch, @@ -1506,6 +3211,7 @@ def tick_circuit_to_stim( tc: TickCircuit, *, p1: float = 0.0, + p1_gate_rates: Mapping[str, float] | None = None, p2: float = 0.0, p_meas: float = 0.0, p_prep: float = 0.0, @@ -1518,6 +3224,11 @@ def tick_circuit_to_stim( Args: tc: TickCircuit instance with detector/observable metadata p1: Single-qubit error rate + p1_gate_rates: Optional per-gate override for single-qubit error + rates. Gate names are PECOS ``GateType`` names such as ``"Z"``, + ``"SZ"``, and ``"SZdg"``. The surface SZZ reference path uses + this to mirror the staged PECOS device model where Z/SZ/SZdg frame + updates are virtual and p1-free. p2: Two-qubit error rate p_meas: Measurement error rate p_prep: Initialization error rate @@ -1532,13 +3243,22 @@ def tick_circuit_to_stim( simple_gate_map = { "H": ("H", "single"), + "SX": ("SQRT_X", "single"), + "SXdg": ("SQRT_X_DAG", "single"), + "SY": ("SQRT_Y", "single"), + "SYdg": ("SQRT_Y_DAG", "single"), + "SZ": ("S", "single"), + "SZdg": ("S_DAG", "single"), "X": ("X", "single"), "Y": ("Y", "single"), "Z": ("Z", "single"), "CX": ("CX", "two"), "CY": ("CY", "two"), "CZ": ("CZ", "two"), + "SZZ": ("SQRT_ZZ", "two"), + "SZZdg": ("SQRT_ZZ_DAG", "two"), "MZ": ("M", "measure"), + "MeasureFree": ("M", "measure"), "PZ": ("R", "prep"), "QAlloc": ("R", "prep"), } @@ -1578,6 +3298,12 @@ def _gate_to_stim( msg = f"Unsupported traced Clifford RZ angle: {angle!r}" raise ValueError(msg) + if gate_name == "F": + return [("S_DAG", qubits), ("H", qubits)], "single" + + if gate_name == "Fdg": + return [("H", qubits), ("S", qubits)], "single" + if gate_name == "RZZ": if not gate.angles: return [], None @@ -1643,8 +3369,9 @@ def _gate_to_stim( op_qubit_str = " ".join(str(q) for q in op_qubits) lines.append(f"{stim_name} {op_qubit_str}") - if noise_kind == "single" and p1 > 0: - lines.append(f"DEPOLARIZE1({p1}) {qubit_str}") + p1_for_gate = p1 if p1_gate_rates is None else float(p1_gate_rates.get(gate.gate_type.name, p1)) + if noise_kind == "single" and p1_for_gate > 0: + lines.append(f"DEPOLARIZE1({p1_for_gate}) {qubit_str}") elif noise_kind == "two" and p2 > 0: lines.append(f"DEPOLARIZE2({p2}) {qubit_str}") elif noise_kind == "prep" and p_prep > 0: @@ -1658,9 +3385,10 @@ def _gate_to_stim( detectors_json = tc.get_meta("detectors") if detectors_json: detectors = json.loads(detectors_json) + num_measurements = int(tc.get_meta("num_measurements") or "0") for det in detectors: coords = det["coords"] - records = det["records"] + records = _metadata_record_offsets(det, num_measurements) coord_str = ", ".join(str(c) for c in coords) record_str = " ".join(f"rec[{r}]" for r in records) lines.append(f"DETECTOR({coord_str}) {record_str}") @@ -1669,9 +3397,10 @@ def _gate_to_stim( observables_json = tc.get_meta("observables") if observables_json: observables = json.loads(observables_json) + num_measurements = int(tc.get_meta("num_measurements") or "0") for obs in observables: obs_id = obs["id"] - records = obs["records"] + records = _metadata_record_offsets(obs, num_measurements) record_str = " ".join(f"rec[{r}]" for r in records) lines.append(f"OBSERVABLE_INCLUDE({obs_id}) {record_str}") @@ -1723,6 +3452,7 @@ def generate_dem_from_tick_circuit_via_pauli_frame( *, p1: float = 0.01, p2: float = 0.01, + p2_weights: Mapping[str, float] | None = None, p_meas: float = 0.01, p_prep: float = 0.01, ) -> str: @@ -1739,6 +3469,9 @@ def generate_dem_from_tick_circuit_via_pauli_frame( tc: TickCircuit with detector/observable metadata p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to + 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement error rate p_prep: Initialization (prep) error rate @@ -1765,14 +3498,14 @@ def generate_dem_from_tick_circuit_via_pauli_frame( meas_to_detectors: dict[int, list[int]] = defaultdict(list) for det in detectors: det_id = det["id"] - for rec in det["records"]: + for rec in _metadata_record_offsets(det, num_measurements): abs_meas = num_measurements + rec # rec is negative meas_to_detectors[abs_meas].append(det_id) meas_to_observables: dict[int, list[int]] = defaultdict(list) for obs in observables: obs_id = obs["id"] - for rec in obs["records"]: + for rec in _metadata_record_offsets(obs, num_measurements): abs_meas = num_measurements + rec meas_to_observables[abs_meas].append(obs_id) @@ -1910,9 +3643,30 @@ def simulate_error( # Single-qubit Paulis for depolarizing noise single_paulis = ["X", "Y", "Z"] # Two-qubit Paulis (non-identity on at least one qubit) - two_paulis = [ - (p1, p2) for p1 in ["I", "X", "Y", "Z"] for p2 in ["I", "X", "Y", "Z"] if not (p1 == "I" and p2 == "I") - ] + two_pauli_labels = tuple( + f"{p_ctrl}{p_targ}" + for p_ctrl in ("I", "X", "Y", "Z") + for p_targ in ("I", "X", "Y", "Z") + if not (p_ctrl == "I" and p_targ == "I") + ) + if p2_weights is None: + two_paulis = tuple((label[0], label[1], 1.0 / 15.0) for label in two_pauli_labels) + else: + from math import isfinite + + weights = {str(label).upper(): float(weight) for label, weight in p2_weights.items()} + unknown_labels = sorted(set(weights) - set(two_pauli_labels)) + if unknown_labels: + message = f"p2_weights contains invalid Pauli labels: {unknown_labels}" + raise ValueError(message) + if any(not isfinite(weight) or weight < 0.0 for weight in weights.values()): + message = "p2_weights values must be finite and non-negative" + raise ValueError(message) + weight_sum = sum(weights.values()) + if abs(weight_sum - 1.0) >= 1.0e-6: + message = f"p2_weights relative probabilities must sum to 1.0, got {weight_sum}" + raise ValueError(message) + two_paulis = tuple((label[0], label[1], weight) for label, weight in sorted(weights.items()) if weight > 0.0) # Process each gate as a potential error location for op_idx, (_tick_idx, gate_name, qubits, meas_idx) in enumerate(circuit_ops): @@ -1936,7 +3690,7 @@ def simulate_error( elif gate_name == "CX" and p2 > 0: # Two-qubit gate error: depolarizing (each Pauli pair with prob p2/15) ctrl, targ = qubits[0], qubits[1] - for p_ctrl, p_targ in two_paulis: + for p_ctrl, p_targ, relative_probability in two_paulis: frame = {} if p_ctrl != "I": frame[ctrl] = p_ctrl @@ -1945,7 +3699,7 @@ def simulate_error( dets, obs = simulate_error(op_idx + 1, frame) if dets or obs: key = (frozenset(dets), frozenset(obs)) - error_mechanisms[key] += p2 / 15 + error_mechanisms[key] += p2 * relative_probability elif gate_name == "MZ" and p_meas > 0: # Measurement error: bit flip (affects this measurement directly) @@ -1993,6 +3747,7 @@ def generate_dem_from_tick_circuit_via_stim( tc: TickCircuit, *, p1: float = 0.01, + p1_gate_rates: Mapping[str, float] | None = None, p2: float = 0.01, p_meas: float = 0.01, p_prep: float = 0.01, @@ -2008,6 +3763,10 @@ def generate_dem_from_tick_circuit_via_stim( Args: tc: TickCircuit with detector/observable metadata p1: Single-qubit depolarizing error rate + p1_gate_rates: Optional per-gate override for single-qubit + depolarizing rates. Gate names are PECOS ``GateType`` names. The + surface SZZ reference path uses this to mirror the staged PECOS + device model where Z/SZ/SZdg frame updates are virtual and p1-free. p2: Two-qubit depolarizing error rate p_meas: Measurement error rate p_prep: Initialization (prep) error rate @@ -2027,7 +3786,14 @@ def generate_dem_from_tick_circuit_via_stim( msg = "Stim is required for this function. Install with: pip install stim" raise ImportError(msg) from e - stim_str = tick_circuit_to_stim(tc, p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + stim_str = tick_circuit_to_stim( + tc, + p1=p1, + p1_gate_rates=p1_gate_rates, + p2=p2, + p_meas=p_meas, + p_prep=p_prep, + ) circuit = stim.Circuit(stim_str) dem = circuit.detector_error_model(decompose_errors=decompose_errors or maximal_decomposition) if maximal_decomposition: @@ -2059,7 +3825,7 @@ def _extract_measurement_order(tc: TickCircuit) -> list[int]: gates = tick.gate_batches() for gate in gates: gate_type = str(gate.gate_type) - if "MZ" in gate_type: + if "MZ" in gate_type or "MeasureFree" in gate_type: # Add each measured qubit to the order for qubit in gate.qubits: # Qubit might be an int or a QubitId object @@ -2132,16 +3898,79 @@ def _maximally_decompose_graphlike_dem(dem_text: str) -> str: return "\n".join(rewritten_lines) +def _build_canonical_dem_influence_map( + dag: DagCircuit, + *, + include_circuit_annotations: bool = False, +) -> object: + """Build the influence map used by the metadata-driven Rust DEM builder.""" + from pecos.qec import DagFaultAnalyzer + + analyzer = DagFaultAnalyzer(dag) + influence_map = analyzer.build_influence_map() + if include_circuit_annotations: + from pecos.qec import InfluenceBuilder + + annotation_builder = InfluenceBuilder(dag) + annotation_builder.with_circuit_annotations() + annotation_map = annotation_builder.build() + merge_dem_outputs = getattr(influence_map, "merge_dem_outputs_from", None) + if merge_dem_outputs is not None: + merge_dem_outputs(annotation_map) + return influence_map + + +def _metadata_uses_record_offsets(*metadata_jsons: str | None) -> bool: + """Return whether detector/observable metadata uses positional records.""" + import json + + for metadata_json in metadata_jsons: + if not metadata_json: + continue + for entry in json.loads(metadata_json): + if entry.get("records"): + return True + return False + + +def _metadata_record_offsets(entry: dict[str, object], num_measurements: int) -> list[int]: + """Return Stim-style negative record offsets for a metadata entry.""" + records = entry.get("records") + if records is not None: + return [int(record) for record in records] # type: ignore[union-attr] + + meas_ids = entry.get("meas_ids") + if meas_ids is not None: + return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] + + msg = "detector/observable metadata entry must define either 'records' or 'meas_ids'" + raise ValueError(msg) + + def generate_dem_from_tick_circuit( tc: TickCircuit, *, p1: float = 0.01, + p1_weights: Mapping[str, float] | None = None, p2: float = 0.01, + p2_weights: Mapping[str, float] | None = None, p_meas: float = 0.01, p_prep: float = 0.01, p_idle: float | None = None, t1: float | None = None, t2: float | None = None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, + p_idle_x_linear_rate: float | None = None, + p_idle_y_linear_rate: float | None = None, + p_idle_z_linear_rate: float | None = None, + p_idle_x_quadratic_rate: float | None = None, + p_idle_y_quadratic_rate: float | None = None, + p_idle_z_quadratic_rate: float | None = None, + p_idle_quadratic_sine_rate: float | None = None, + p_idle_x_quadratic_sine_rate: float | None = None, + p_idle_y_quadratic_sine_rate: float | None = None, + p_idle_z_quadratic_sine_rate: float | None = None, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -2165,14 +3994,35 @@ def generate_dem_from_tick_circuit( Args: tc: TickCircuit with detector/observable metadata (required) - p1: Single-qubit depolarizing error rate + p1: Single-qubit Pauli error rate + p1_weights: Optional relative probabilities over single-qubit Pauli + errors (``X``, ``Y``, ``Z``). Values must sum to 1.0; ``p1`` + remains the total single-qubit error rate. p2: Two-qubit depolarizing error rate + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to + 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement error rate p_prep: Initialization (prep) error rate p_idle: Optional idle noise rate per explicit idle-gate time unit. The caller is responsible for inserting idle gates where needed. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. + p_idle_linear_rate: Optional legacy alias for stochastic Z-memory rate + linear in idle duration. + p_idle_quadratic_rate: Optional legacy alias for stochastic Z-memory rate + quadratic in idle duration. + p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. + p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. + p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. + p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. + p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. + p_idle_quadratic_sine_rate: Optional legacy alias for stochastic Z-memory + rate with probability ``sin(rate * duration)^2``. + p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. + p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. + p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. decompose_errors: If True (default), decompose hyperedge errors into graphlike components using the `^` separator. Set to False to output raw hyperedges. Ignored if maximal_decomposition=True. @@ -2183,7 +4033,7 @@ def generate_dem_from_tick_circuit( Returns: DEM string in Stim-compatible format """ - from pecos.qec import DagFaultAnalyzer, DemBuilder + from pecos.qec import DemBuilder # Get detector and observable metadata detectors_json = tc.get_meta("detectors") @@ -2199,17 +4049,42 @@ def generate_dem_from_tick_circuit( # This allows proper mapping between record offsets (TickCircuit order) and # influence map indices (DAG topological order). measurement_order = _extract_measurement_order(tc) + metadata_uses_records = _metadata_uses_record_offsets(detectors_json, observables_json) # Convert TickCircuit to DagCircuit and build influence map dag = tc.to_dag_circuit() - analyzer = DagFaultAnalyzer(dag) - influence_map = analyzer.build_influence_map() + influence_map = _build_canonical_dem_influence_map(dag) # Build DEM using Rust DemBuilder builder = DemBuilder(influence_map) - builder.with_noise(p1, p2, p_meas, p_prep, p_idle=p_idle, t1=t1, t2=t2) + builder.with_noise( + p1, + p2, + p_meas, + p_prep, + p1_weights=p1_weights, + p2_weights=p2_weights, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + if hasattr(builder, "with_exact_branch_replay_circuit"): + builder = builder.with_exact_branch_replay_circuit(dag) builder.with_num_measurements(num_measurements) - builder.with_measurement_order(measurement_order) + if metadata_uses_records: + builder.with_measurement_order(measurement_order) builder.with_detectors_json(detectors_json) if observables_json: builder.with_observables_json(observables_json) @@ -2219,6 +4094,9 @@ def generate_dem_from_tick_circuit( if maximal_decomposition: return _maximally_decompose_graphlike_dem(dem.to_string_decomposed()) if decompose_errors: + source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) + if source_graphlike is not None: + return source_graphlike() return dem.to_string_decomposed() return dem.to_string() diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e65994390..5c6b8ee0b 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -43,20 +43,33 @@ from __future__ import annotations -from dataclasses import dataclass +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, replace from enum import Enum from functools import cache from typing import TYPE_CHECKING, Any, Literal import numpy as np +from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan +from pecos.quantum import validate_hosted_operations + if TYPE_CHECKING: import stim from numpy.typing import NDArray + from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import Stabilizer, SurfacePatch +P1Weights = Mapping[str, float] | Sequence[tuple[str, float]] +P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] +# Native graphlike decompositions are decoder-facing projections of raw +# hyperedge mechanisms, not alternate exact DEM serializations. +NativeDemDecomposition = Literal["source_graphlike", "terminal_graphlike"] +CircuitLevelDemMode = Literal["native_full", "native_decomposed", "native_terminal_graphlike"] + + def _validate_probability(name: str, value: float) -> float: """Return ``value`` as a float after validating it is a probability.""" probability = float(value) @@ -70,6 +83,8 @@ class DecoderType(str, Enum): """Available decoder backends.""" PYMATCHING = "pymatching" + PYMATCHING_CORRELATED = "pymatching_correlated" + PYMATCHING_UNCORRELATED = "pymatching_uncorrelated" FUSION_BLOSSOM = "fusion_blossom" BP_OSD = "bp_osd" BP_LSD = "bp_lsd" @@ -77,6 +92,20 @@ class DecoderType(str, Enum): TESSERACT = "tesseract" +DEM_DECODER_TYPES = { + DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, + DecoderType.TESSERACT, +} + +PYMATCHING_DECODER_TYPES = { + DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, +} + + @dataclass class NoiseModel: """Circuit-level noise parameters for QEC simulation. @@ -86,21 +115,119 @@ class NoiseModel: Attributes: p1: Single-qubit gate error rate. + p1_weights: Optional relative probabilities over single-qubit Pauli + error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must sum to + 1.0; ``p1`` remains the total single-qubit error rate. p2: Two-qubit gate error rate. + p2_szz: Optional total error-rate override for ``SZZ`` gates. When + unset, ``SZZ`` uses ``p2``. + p2_szzdg: Optional total error-rate override for ``SZZdg`` gates. When + unset, ``SZZdg`` uses ``p2``. + p2_weights: Optional relative probabilities over two-qubit Pauli error + labels. Plain labels such as ``"XX"`` are post-gate Pauli branches; + labels prefixed by ``"*"`` such as ``"*XX"`` are replacement + branches that omit the ideal two-qubit gate before applying the + Pauli. Values must sum to 1.0; ``p2`` remains the total two-qubit + error rate. + p2_replacement_approximation: Approximation used for starred + replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with + the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` + evaluates starred entries as replacement branch impacts; + ``"exact_branch_replay"`` uses the traced circuit context to replay + omitted-gate branches at concrete two-qubit gate locations and + fails loudly when a branch is not DEM-representable; + ``"ignore_gate_removal"`` treats starred entries like plain + post-gate Pauli entries. p_meas: Measurement error rate. p_prep: Initialization error rate. p_idle: Idle noise rate per time unit (uniform depolarizing). t1: T1 relaxation time for idle noise (same units as idle duration). t2: T2 dephasing time (must satisfy t2 <= 2*t1). + p_idle_linear_rate: Legacy alias for stochastic Z-memory rate linear in idle duration. + p_idle_quadratic_rate: Legacy alias for stochastic Z-memory rate quadratic in idle duration. + p_idle_x_linear_rate: Stochastic X-memory rate linear in idle duration. + p_idle_y_linear_rate: Stochastic Y-memory rate linear in idle duration. + p_idle_z_linear_rate: Stochastic Z-memory rate linear in idle duration. + p_idle_x_quadratic_rate: Stochastic X-memory rate quadratic in idle duration. + p_idle_y_quadratic_rate: Stochastic Y-memory rate quadratic in idle duration. + p_idle_z_quadratic_rate: Stochastic Z-memory rate quadratic in idle duration. + p_idle_quadratic_sine_rate: Legacy alias for stochastic Z-memory rate + with probability ``sin(rate * duration)^2``. + p_idle_x_quadratic_sine_rate: Stochastic X-memory sine-law rate. + p_idle_y_quadratic_sine_rate: Stochastic Y-memory sine-law rate. + p_idle_z_quadratic_sine_rate: Stochastic Z-memory sine-law rate. """ p1: float = 0.0 + p1_weights: P1Weights | None = None p2: float = 0.0 + p2_szz: float | None = None + p2_szzdg: float | None = None + p2_weights: P2Weights | None = None + p2_replacement_approximation: str | None = None p_meas: float = 0.0 p_prep: float = 0.0 p_idle: float | None = None t1: float | None = None t2: float | None = None + p_idle_linear_rate: float | None = None + p_idle_quadratic_rate: float | None = None + p_idle_x_linear_rate: float | None = None + p_idle_y_linear_rate: float | None = None + p_idle_z_linear_rate: float | None = None + p_idle_x_quadratic_rate: float | None = None + p_idle_y_quadratic_rate: float | None = None + p_idle_z_quadratic_rate: float | None = None + p_idle_quadratic_sine_rate: float | None = None + p_idle_x_quadratic_sine_rate: float | None = None + p_idle_y_quadratic_sine_rate: float | None = None + p_idle_z_quadratic_sine_rate: float | None = None + + def __post_init__(self) -> None: + """Normalize cache-sensitive inputs after dataclass initialization.""" + self.p1_weights = _normalize_p1_weights(self.p1_weights) + self.p2_weights = _normalize_p2_weights(self.p2_weights) + if self.p2_szz is not None: + self.p2_szz = _validate_probability("p2_szz", self.p2_szz) + if self.p2_szzdg is not None: + self.p2_szzdg = _validate_probability("p2_szzdg", self.p2_szzdg) + + @property + def effective_p_idle_z_linear_rate(self) -> float | None: + """Z-axis linear idle rate, accepting the legacy alias.""" + return self.p_idle_z_linear_rate if self.p_idle_z_linear_rate is not None else self.p_idle_linear_rate + + @property + def effective_p_idle_z_quadratic_rate(self) -> float | None: + """Z-axis quadratic idle rate, accepting the legacy alias.""" + return self.p_idle_z_quadratic_rate if self.p_idle_z_quadratic_rate is not None else self.p_idle_quadratic_rate + + @property + def effective_p_idle_z_quadratic_sine_rate(self) -> float | None: + """Z-axis sine-law quadratic idle rate, accepting the legacy alias.""" + if self.p_idle_z_quadratic_sine_rate is not None: + return self.p_idle_z_quadratic_sine_rate + return self.p_idle_quadratic_sine_rate + + @property + def idle_memory_rates(self) -> tuple[float | None, ...]: + """All dedicated Pauli idle-memory rates that require explicit idles.""" + return ( + self.p_idle_x_linear_rate, + self.p_idle_y_linear_rate, + self.effective_p_idle_z_linear_rate, + self.p_idle_x_quadratic_rate, + self.p_idle_y_quadratic_rate, + self.effective_p_idle_z_quadratic_rate, + self.p_idle_x_quadratic_sine_rate, + self.p_idle_y_quadratic_sine_rate, + self.effective_p_idle_z_quadratic_sine_rate, + ) + + @property + def p2_gate_rates(self) -> tuple[float | None, ...]: + """Explicit two-qubit gate-rate overrides.""" + return (self.p2_szz, self.p2_szzdg) @staticmethod def uniform(physical_error_rate: float) -> NoiseModel: @@ -114,20 +241,58 @@ def is_noiseless(self) -> bool: return ( self.p1 == 0.0 and self.p2 == 0.0 + and all(rate is None or rate == 0.0 for rate in self.p2_gate_rates) and self.p_meas == 0.0 and self.p_prep == 0.0 and (self.p_idle is None or self.p_idle == 0.0) + and all(rate is None or rate == 0.0 for rate in self.idle_memory_rates) ) @property def physical_error_rate(self) -> float: """Approximate combined physical error rate.""" rates = [self.p1, self.p2, self.p_meas, self.p_prep] + rates.extend(rate for rate in self.p2_gate_rates if rate is not None) if self.p_idle is not None: rates.append(self.p_idle) + rates.extend(rate for rate in self.idle_memory_rates if rate is not None) return max(rates) +def _normalize_pauli_weights(weights: P1Weights | P2Weights | None) -> tuple[tuple[str, float], ...] | None: + if weights is None: + return None + items = weights.items() if isinstance(weights, Mapping) else weights + return tuple(sorted((str(label).upper(), float(weight)) for label, weight in items)) + + +def _normalize_p1_weights(p1_weights: P1Weights | None) -> tuple[tuple[str, float], ...] | None: + return _normalize_pauli_weights(p1_weights) + + +def _p1_weights_dict(p1_weights: P1Weights | None) -> dict[str, float] | None: + normalized = _normalize_p1_weights(p1_weights) + return None if normalized is None else dict(normalized) + + +def _normalize_p2_weights(p2_weights: P2Weights | None) -> tuple[tuple[str, float], ...] | None: + return _normalize_pauli_weights(p2_weights) + + +def _p2_weights_dict(p2_weights: P2Weights | None) -> dict[str, float] | None: + normalized = _normalize_p2_weights(p2_weights) + return None if normalized is None else dict(normalized) + + +def _p2_gate_rates_dict(noise: NoiseModel) -> dict[str, float] | None: + rates: dict[str, float] = {} + if noise.p2_szz is not None: + rates["SZZ"] = noise.p2_szz + if noise.p2_szzdg is not None: + rates["SZZdg"] = noise.p2_szzdg + return rates or None + + @dataclass class DecodingResult: """Result from decoding a single shot.""" @@ -143,13 +308,22 @@ class DecodingResult: class _CachedNativeSurfaceTopology: """Topology-only native model data reused across noise configurations.""" + dag_circuit: Any influence_map: Any + szz_physical_prefixes: bool + z_frame_gate_p1_free: bool + pauli_frame_lookup: Any | None detectors_json: str observables_json: str measurement_order: tuple[int, ...] num_measurements: int num_detectors: int num_observables: int + num_pauli_sites: int + interaction_basis: str + check_plan: str + resolved_check_plan: dict[str, Any] + resolved_check_plan_hash: str def _surface_patch_cache_key(patch: SurfacePatch) -> tuple[int, int, str, bool]: @@ -176,6 +350,25 @@ def _cached_surface_patch(patch_key: tuple[int, int, str, bool]) -> SurfacePatch ) +def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: + """Drop runtime-only Guppy record-framing fields before DEM caching.""" + if twirl is None: + return None + twirl.validate_runtime_supported() + return replace(twirl, frame_output="raw", twirl_probability=1.0) + + +def _twirl_traced_qis_rejection_message() -> str: + return ( + "twirl=TwirlConfig() is not supported with circuit_source='traced_qis': " + "tracing runtime RNG twirl would bake one concrete mask realization " + "into the circuit/DEM/lookup, and canonical frame_output may break " + "runtime measurement result-id provenance because measurement tags can " + "be XOR-derived expressions. Use circuit_source='abstract' for twirl " + "for now." + ) + + def syndromes_to_detection_events( syndromes: NDArray[np.uint8], num_rounds: int, @@ -389,8 +582,16 @@ def det_id(round_: int, stab_idx: int) -> int: return "\n".join(lines) -def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: +def _copy_surface_tick_circuit_metadata( + source_tc: Any, + target_tc: Any, + *, + measurement_index_remap: dict[int, int] | None = None, +) -> None: """Copy the surface-level metadata needed by the native DEM/sampler builders.""" + num_measurements_text = source_tc.get_meta("num_measurements") + num_measurements = int(num_measurements_text) if num_measurements_text is not None else None + for key in ( "basis", "detectors", @@ -403,15 +604,339 @@ def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: ): value = source_tc.get_meta(key) if value is not None: + if measurement_index_remap is not None and key in ( + "detectors", + "observables", + "detector_descriptors", + "observable_descriptors", + ): + if num_measurements is None: + msg = "Cannot remap surface metadata without num_measurements" + raise ValueError(msg) + value = _remap_surface_record_metadata_json( + value, + measurement_index_remap=measurement_index_remap, + num_measurements=num_measurements, + ) target_tc.set_meta(key, value) -def _replay_qis_trace_into_tick_circuit(operations: list[dict[str, Any]]) -> Any: +def _measurement_index_remap_for_orders( + abstract_measurement_order: list[int], + traced_measurement_order: list[int], +) -> dict[int, int]: + """Map abstract record indices to runtime-traced record indices. + + The detector metadata is generated from the abstract surface schedule, but + a runtime may legally reorder measurement operations while preserving the + same measured qubit occurrences. This helper binds each measurement by + ``(qubit, occurrence_count_for_that_qubit)`` so metadata can follow a pure + scheduling reorder without accepting dropped/extra/wrong measurements. + """ + from collections import Counter, defaultdict + + if len(abstract_measurement_order) != len(traced_measurement_order) or Counter( + abstract_measurement_order, + ) != Counter(traced_measurement_order): + msg = ( + "Traced and abstract surface circuits disagree on the measured-qubit " + "multiset; refusing to remap detector/observable metadata" + ) + raise ValueError(msg) + + traced_occurrences: dict[tuple[int, int], int] = {} + traced_counts: defaultdict[int, int] = defaultdict(int) + for traced_index, qubit in enumerate(traced_measurement_order): + occurrence = traced_counts[qubit] + traced_occurrences[(qubit, occurrence)] = traced_index + traced_counts[qubit] += 1 + + remap: dict[int, int] = {} + abstract_counts: defaultdict[int, int] = defaultdict(int) + for abstract_index, qubit in enumerate(abstract_measurement_order): + occurrence = abstract_counts[qubit] + remap[abstract_index] = traced_occurrences[(qubit, occurrence)] + abstract_counts[qubit] += 1 + + return remap + + +def _remap_surface_record_metadata_json( + metadata_json: str, + *, + measurement_index_remap: dict[int, int], + num_measurements: int, +) -> str: + """Bind abstract measurement refs to runtime-stable ``meas_ids``. + + ``measurement_index_remap`` maps abstract measurement indices to the + stable result ids emitted by the runtime trace. Those ids are not + positional record offsets, so remapped runtime metadata must use + ``meas_ids`` and must drop stale ``records``. + """ + import json + + entries = json.loads(metadata_json) + for entry in entries: + records = entry.pop("records", None) + if records is not None: + abstract_indices = [] + for record in records: + abstract_index = num_measurements + int(record) + if abstract_index not in measurement_index_remap: + msg = f"Surface metadata record {record!r} is out of range for remapping" + raise ValueError(msg) + abstract_indices.append(abstract_index) + elif "meas_ids" in entry: + abstract_indices = [int(meas_id) for meas_id in entry["meas_ids"]] + else: + continue + + remapped_meas_ids = [] + for abstract_index in abstract_indices: + if abstract_index not in measurement_index_remap: + msg = f"Surface metadata meas_id {abstract_index!r} is out of range for remapping" + raise ValueError(msg) + remapped_meas_ids.append(int(measurement_index_remap[abstract_index])) + entry["meas_ids"] = remapped_meas_ids + return json.dumps(entries) + + +def _surface_runtime_measurement_remap_from_result_traces( + abstract_tc: Any, + result_traces: list[dict[str, Any]], +) -> dict[int, int]: + """Map abstract surface measurement indices to runtime ``result_id``s. + + The generated surface Guppy emits scalar counted-round + ``result("sx*/sz*:meas:N", bit)`` tags, prep-boundary + ``result("sx*/sz*:init:meas:N", bit)`` tags, and one + ``result("final", array(...))`` call for data readout. The abstract + TickCircuit labels each measurement with the result tag it should bind to. + Those tags survive runtime scheduling changes and are the stable + detector/observable anchor. + """ + num_measurements = int(abstract_tc.get_meta("num_measurements")) + scalar_trace_ids, array_trace_ids = _index_surface_result_trace_ids(result_traces) + abstract_refs = _surface_abstract_measurement_result_refs(abstract_tc) + if len(abstract_refs) != num_measurements: + msg = f"expected {num_measurements} abstract measurement refs, got {len(abstract_refs)}" + raise ValueError(msg) + + occurrence_by_tag: dict[str, int] = {} + remap: dict[int, int] = {} + for abstract_index, ref in enumerate(abstract_refs): + if ref[0] == "scalar": + _, name = ref + occurrence = occurrence_by_tag.get(name, 0) + occurrence_by_tag[name] = occurrence + 1 + try: + remap[abstract_index] = scalar_trace_ids[name][occurrence] + except (KeyError, IndexError) as exc: + msg = f"result tag {name!r} occurrence {occurrence} is missing from the runtime trace" + raise ValueError(msg) from exc + else: + _, name, element = ref + try: + remap[abstract_index] = array_trace_ids[name][0][element] + except (KeyError, IndexError) as exc: + msg = f"result tag {name!r}[{element}] is missing from the runtime trace" + raise ValueError(msg) from exc + + runtime_ids = sorted(remap.values()) + if runtime_ids != list(range(num_measurements)): + msg = ( + "Runtime result-tag provenance is not a dense measurement-id range " + f"0..{num_measurements - 1}; got first/last " + f"{runtime_ids[:3]}...{runtime_ids[-3:]}" + ) + raise ValueError(msg) + return remap + + +def _index_surface_result_trace_ids( + result_traces: Sequence[Mapping[str, Any]], +) -> tuple[dict[str, list[int]], dict[str, list[list[int]]]]: + """Index runtime named-result provenance by tag name.""" + scalar_trace_ids: dict[str, list[int]] = {} + array_trace_ids: dict[str, list[list[int]]] = {} + for trace in result_traces: + name = trace.get("name") + values = trace.get("values") + result_ids = trace.get("result_ids") + if not isinstance(name, str) or not isinstance(values, list) or not isinstance(result_ids, list): + continue + if _is_surface_sideband_result_tag(name): + continue + if len(values) != len(result_ids): + msg = ( + f"runtime result tag {name!r} has {len(values)} value(s) but " + f"{len(result_ids)} result id(s); cannot bind surface metadata" + ) + raise ValueError(msg) + ids = [int(result_id) for result_id in result_ids] + is_scalar_syndrome_tag = name.startswith(("sx", "sz")) and ":meas:" in name + if is_scalar_syndrome_tag and len(ids) == 1: + scalar_trace_ids.setdefault(name, []).append(ids[0]) + else: + array_trace_ids.setdefault(name, []).append(ids) + if not scalar_trace_ids and not array_trace_ids: + msg = "runtime trace does not contain named_result_traces; rebuild PECOS with result-tag provenance support" + raise ValueError(msg) + return scalar_trace_ids, array_trace_ids + + +def _is_surface_sideband_result_tag(name: str) -> bool: + """Return true for non-detector-bearing surface result tags.""" + return name.startswith(("pauli_mask:", "pauli_active:", "frame_mode:", "raw:")) + + +def _surface_abstract_measurement_result_refs(abstract_tc: Any) -> list[tuple[str, str] | tuple[str, str, int]]: + """Return the result-tag reference for each abstract surface measurement.""" + refs: list[tuple[str, str] | tuple[str, str, int]] = [] + syndrome_measure_index_by_round: dict[int, int] = {} + measurement_gate_types = {"MZ", "MeasureFree"} + for tick_index in range(abstract_tc.num_ticks()): + tick = abstract_tc.get_tick(tick_index) + if tick is None: + continue + for gate_index, gate in enumerate(tick.gate_batches()): + gate_type = str(getattr(gate, "gate_type", "")).rsplit(".", maxsplit=1)[-1] + if gate_type not in measurement_gate_types: + continue + label = str(abstract_tc.get_gate_meta(tick_index, gate_index, "label") or "") + if label.startswith(("sx", "sz")): + round_value = abstract_tc.get_gate_meta(tick_index, gate_index, "syndrome_round") + if round_value is None: + msg = f"surface syndrome measurement {label!r} is missing syndrome_round metadata" + raise ValueError(msg) + round_index = int(round_value) + measurement_index = syndrome_measure_index_by_round.get(round_index, 0) + syndrome_measure_index_by_round[round_index] = measurement_index + 1 + phase = "init:meas" if round_index < 0 else "meas" + refs.append(("scalar", f"{label}:{phase}:{measurement_index}")) + continue + if label.startswith("final[") and label.endswith("]"): + refs.append(("array", "final", int(label.removeprefix("final[").removesuffix("]")))) + continue + msg = f"surface measurement is missing a result-tag-compatible label: {label!r}" + raise ValueError(msg) + return refs + + +def _extract_measurement_meas_ids(tc: Any) -> list[int]: + """Return stable measurement ids in TickCircuit execution order.""" + ids: list[int] = [] + for tick_idx in range(tc.num_ticks()): + tick = tc.get_tick(tick_idx) + if tick is None: + continue + for gate in tick.gate_batches(): + gate_type = str(getattr(gate, "gate_type", "")).rsplit(".", maxsplit=1)[-1] + if gate_type not in {"MZ", "MeasureFree"}: + continue + qubits = list(getattr(gate, "qubits", [])) + meas_ids = list(getattr(gate, "meas_ids", [])) + if len(meas_ids) != len(qubits): + msg = ( + f"traced measurement gate {gate_type} in tick {tick_idx} carries " + f"{len(meas_ids)} MeasId(s) for {len(qubits)} qubit(s)" + ) + raise ValueError(msg) + ids.extend(int(meas_id) for meas_id in meas_ids) + return ids + + +def _validate_result_tag_remap_against_traced_measurements( + traced_tc: Any, + measurement_index_remap: Mapping[int, int], + *, + expected_measurements: int, +) -> None: + """Fail loudly unless result-tag bindings exactly cover traced MeasIds.""" + expected_abstract_indices = list(range(expected_measurements)) + actual_abstract_indices = sorted(measurement_index_remap) + if actual_abstract_indices != expected_abstract_indices: + msg = ( + "runtime result-tag remap does not cover every abstract measurement; " + f"expected indices {expected_abstract_indices[:3]}...{expected_abstract_indices[-3:]}, " + f"got {actual_abstract_indices[:3]}...{actual_abstract_indices[-3:]}" + ) + raise ValueError(msg) + + traced_meas_ids = _extract_measurement_meas_ids(traced_tc) + if len(traced_meas_ids) != expected_measurements: + msg = ( + "traced circuit contains " + f"{len(traced_meas_ids)} measured MeasId(s), but result-tag metadata " + f"expects {expected_measurements}" + ) + raise ValueError(msg) + if len(set(traced_meas_ids)) != len(traced_meas_ids): + duplicates = sorted(meas_id for meas_id in set(traced_meas_ids) if traced_meas_ids.count(meas_id) > 1) + msg = f"traced circuit contains duplicate measured MeasId(s): {duplicates[:8]}" + raise ValueError(msg) + + expected_meas_ids = sorted(int(meas_id) for meas_id in measurement_index_remap.values()) + actual_meas_ids = sorted(traced_meas_ids) + if actual_meas_ids != expected_meas_ids: + expected_set = set(expected_meas_ids) + actual_set = set(actual_meas_ids) + missing = sorted(expected_set - actual_set) + extra = sorted(actual_set - expected_set) + msg = ( + "runtime result-tag bindings do not exactly match the traced circuit's " + f"measured MeasIds; missing={missing[:8]}, extra={extra[:8]}" + ) + raise ValueError(msg) + + +def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: + """Convert runtime idle seconds into PECOS nanosecond time units.""" + import math + + from pecos_rslib import TimeUnits + + if not math.isfinite(duration_seconds) or duration_seconds < 0.0: + msg = f"Idle duration must be finite and non-negative, got {duration_seconds!r}" + raise ValueError(msg) + + units = round(duration_seconds * 1_000_000_000.0) + if duration_seconds > 0.0: + units = max(1, units) + return TimeUnits(units) + + +def _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology: str | None, +) -> str | None: + if measurement_crosstalk_topology in (None, "none", "runtime_payloads"): + return None + if measurement_crosstalk_topology == "global_from_measurements": + return measurement_crosstalk_topology + msg = "measurement_crosstalk_topology must be None, 'runtime_payloads', or 'global_from_measurements'" + raise ValueError(msg) + + +def _should_add_global_measurement_crosstalk_payload( + measurement_crosstalk_topology: str | None, +) -> bool: + return _validate_measurement_crosstalk_topology(measurement_crosstalk_topology) == "global_from_measurements" + + +def _replay_qis_trace_into_tick_circuit( + operations: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: """Replay traced QIS operations into a PECOS TickCircuit.""" import heapq from pecos_rslib.quantum import TickCircuit + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology, + ) tick_circuit = TickCircuit() active_slots: dict[int, int] = {} free_slots: list[int] = [] @@ -502,6 +1027,12 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: elif op_name == "RXY": theta, phi, program_id = tuple_args(payload, op_name, 3) tick.r1xy(float(theta), float(phi), [mapped_slot(int(program_id), op_name)]) + elif op_name == "Idle": + duration, program_id = tuple_args(payload, op_name, 2) + tick.idle( + _runtime_idle_seconds_to_time_units(float(duration)), + [mapped_slot(int(program_id), op_name)], + ) elif op_name == "CX": control, target = tuple_args(payload, op_name, 2) tick.cx([(mapped_slot(int(control), op_name), mapped_slot(int(target), op_name))]) @@ -542,11 +1073,22 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: ) elif op_name == "Measure": program_id, result_id = tuple_args(payload, op_name, 2) + measurement_qubit = mapped_slot(int(program_id), op_name) + if _should_add_global_measurement_crosstalk_payload( + measurement_crosstalk_topology, + ): + # Global crosstalk payload qubits are guaranteed not to be + # affected; for measurement-induced global crosstalk this is + # exactly the measured payload. + tick_circuit.tick().add_gate( + "MeasCrosstalkGlobalPayload", + [measurement_qubit], + ) # Stamp the QIS-provided result_id as the MeasId rather than # discarding it and letting assign_missing_meas_ids() invent # sequential ids (which would be wrong for non-sequential ids). tick.mz_with_ids( - [mapped_slot(int(program_id), op_name)], + [measurement_qubit], [int(result_id)], ) elif op_name == "Reset": @@ -577,43 +1119,54 @@ def _gate_triples(qubits: list[int], gate_type: str) -> list[tuple[int, int, int return [(qubits[i], qubits[i + 1], qubits[i + 2]) for i in range(0, len(qubits), 3)] -def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> Any: +def _lowered_gate_metadata(gate: Mapping[str, Any]) -> dict[str, Any]: + """Return validated runtime/source metadata for a lowered trace gate.""" + metadata = gate.get("metadata") + if metadata is None: + return {} + if not isinstance(metadata, Mapping): + msg = f"Lowered gate metadata must be an object, got {metadata!r}" + raise TypeError(msg) + return {str(key): value for key, value in metadata.items()} + + +def _set_lowered_gate_metadata(tick: Any, metadata: Mapping[str, Any]) -> None: + """Attach lowered trace metadata to the gate most recently added to ``tick``.""" + if not metadata: + return + tick.metas(metadata) + + +def _replay_lowered_qis_trace_into_tick_circuit( + chunks: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: """Replay lowered post-Selene ByteMessage gate batches into a TickCircuit. The lowered trace emits gates one at a time. We replay each into its own tick, then compact (ASAP schedule) so that gates on disjoint qubits share a tick --- matching the parallel structure of the abstract circuit. - MeasIds flow from the QIS measurement result slot: Quantum.Measure carries - ``[qubit, result_id]``, and those IDs are stamped on MZ gates via - mz_with_ids(). + MeasIds flow from runtime-lowered measurement provenance: + ``lowered_quantum_ops`` MZ entries must carry ``measurement_result_ids``. + This avoids inferring lowered measurement IDs from raw QIS operation order, + which is not stable under runtime scheduling or transport. """ from pecos_rslib.quantum import TickCircuit + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology, + ) tick_circuit = TickCircuit() - # Pass 1: the ordered MeasIds, read directly from each Measure op. A - # ``Quantum.Measure`` op carries ``[qubit, result_id]`` where ``result_id`` - # is the QIS result slot the runtime allocated for it (== the MeasId we - # stamp). Using it directly needs no AllocateResult/Measure pairing - # heuristic and no interleave assumption -- batched - # allocate-allocate-measure-measure (a valid QIS pattern) works the same - # as interleaved. (The order of Measure ops here matches the order of MZ - # gates in ``lowered_quantum_ops``, consumed in pass 2.) - meas_ids_in_order: list[int] = [] - for chunk in chunks: - for op in chunk.get("operations") or []: - quantum = dict(op).get("Quantum") - if isinstance(quantum, dict) and "Measure" in quantum: - meas_ids_in_order.append(int(quantum["Measure"][1])) - - # Pass 2: replay gates, stamping MeasIds on MZ gates in global trace order. - meas_cursor = 0 for chunk in chunks: for gate in chunk.get("lowered_quantum_ops") or []: gate_type = str(gate["gate_type"]) qubits = [int(q) for q in gate.get("qubits", [])] angles = [float(theta) for theta in gate.get("angles", [])] + params = [float(param) for param in gate.get("params", [])] + metadata = _lowered_gate_metadata(gate) tick = tick_circuit.tick() if gate_type == "H": @@ -634,17 +1187,39 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick.tdg(qubits) elif gate_type == "PZ": tick.pz(qubits) + elif gate_type == "Idle": + if len(params) != 1: + msg = f"Lowered Idle gate expected one duration param, got {params!r}" + raise ValueError(msg) + tick.idle(_runtime_idle_seconds_to_time_units(params[0]), qubits) elif gate_type == "MZ": - end = meas_cursor + len(qubits) - if end > len(meas_ids_in_order): + meas_ids = gate.get("measurement_result_ids") + if not isinstance(meas_ids, list): msg = ( - "More measured qubits than result(...)-anchored " - "MeasIds in the traced program; a measurement is " - "missing its result(...) call." + "Lowered MZ trace is missing measurement_result_ids; " + "rebuild PECOS so runtime-lowered measurements carry " + "their result-id provenance instead of relying on " + "operation-order inference." ) raise ValueError(msg) - tick.mz_with_ids(qubits, meas_ids_in_order[meas_cursor:end]) - meas_cursor = end + if len(meas_ids) != len(qubits): + msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" + raise ValueError(msg) + if _should_add_global_measurement_crosstalk_payload( + measurement_crosstalk_topology, + ): + # Global crosstalk payload qubits are guaranteed not to be + # affected; for measurement-induced global crosstalk this is + # exactly the measured payload. + tick_circuit.tick().add_gate( + "MeasCrosstalkGlobalPayload", + qubits, + ) + tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) + elif gate_type == "MeasCrosstalkGlobalPayload": + tick.add_gate("MeasCrosstalkGlobalPayload", qubits) + elif gate_type == "MeasCrosstalkLocalPayload": + tick.add_gate("MeasCrosstalkLocalPayload", qubits) elif gate_type == "RX": tick.rx(angles[0], qubits) elif gate_type == "RY": @@ -674,14 +1249,7 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> else: msg = f"Unsupported lowered traced gate {gate_type!r}" raise ValueError(msg) - - if meas_cursor != len(meas_ids_in_order): - msg = ( - f"Traced program has {len(meas_ids_in_order)} result(...)-anchored " - f"measurements but only {meas_cursor} measured qubit(s) in the " - "lowered gate stream; result()/measurement mismatch." - ) - raise ValueError(msg) + _set_lowered_gate_metadata(tick, metadata) # Compact: ASAP-schedule gates into minimal ticks tick_circuit.compact_ticks() @@ -737,7 +1305,109 @@ def _reject_partially_lowered_trace(chunks: list[dict[str, Any]]) -> None: raise ValueError(msg) -def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = 0) -> Any: +def _replay_qis_trace_chunks_into_tick_circuit( + chunks: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: + """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology, + ) + if any(chunk.get("lowered_quantum_ops") for chunk in chunks): + _reject_partially_lowered_trace(chunks) + try: + return _replay_lowered_qis_trace_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) + except ValueError as exc: + if "missing measurement_result_ids" not in str(exc): + raise + # Older local Selene/qis-compiler builds can emit lowered gates + # without measurement_result_ids while still carrying the raw QIS + # operations, whose Measure payloads include the stable result ids. + # Replay the raw operations in that compatibility case instead of + # losing provenance. + + operations: list[dict[str, Any]] = [] + for chunk in chunks: + operations.extend(list(chunk.get("operations", []))) + return _replay_qis_trace_into_tick_circuit( + operations, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) + + +def named_result_traces_from_operation_trace(chunks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return runtime `result(...)` provenance records from operation trace chunks.""" + traces: list[dict[str, Any]] = [] + for chunk in chunks: + traces.extend(trace for trace in (chunk.get("named_result_traces") or []) if isinstance(trace, dict)) + return traces + + +def capture_guppy_operation_trace( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, +) -> list[dict[str, Any]]: + """Capture a Guppy/QIS program's Selene operation trace chunks.""" + import pecos_rslib + + import pecos + + # Trace capture records the runtime-lowered QIS operations and result tags; + # DEM validation/fault propagation happens after replay. Use a permissive + # trace backend instead of asking stabilizer evolution to validate every + # runtime-emitted rotation while we are only collecting provenance. + sim_builder = ( + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos_rslib.coin_toss()) + .qubits(num_qubits) + .seed(seed) + ) + return list(sim_builder.capture_operation_trace()) + + +def trace_guppy_into_tick_circuit_with_result_traces( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, + measurement_crosstalk_topology: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, +) -> tuple[Any, list[dict[str, Any]]]: + """Trace a Guppy/QIS program into a ``TickCircuit`` plus result-tag provenance.""" + chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) + tick_circuit = _replay_qis_trace_chunks_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) + _validate_trace_hosted_operations_if_requested( + tick_circuit, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + context="trace_guppy_into_tick_circuit_with_result_traces", + ) + return tick_circuit, named_result_traces_from_operation_trace(chunks) + + +def trace_guppy_into_tick_circuit( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, + measurement_crosstalk_topology: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, +) -> Any: """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. Runs ``program`` under the Selene QIS engine with operation tracing enabled @@ -758,32 +1428,53 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = num_qubits: Number of qubits to allocate. QIS/HUGR programs require an explicit qubit count for trace capture. seed: Seed for the (ideal) trace run. + runtime: Optional Selene runtime selector/plugin. ``None`` selects the + default Selene runtime. Runtime plugin objects are passed through to + ``pecos.selene_engine(runtime)``. + measurement_crosstalk_topology: Optional measurement-crosstalk replay + mode for stamping global measurement-crosstalk payload markers. + require_hosted_operation_order: If true, validate generic hosted-operation + metadata after trace replay. A gate with ``local_role`` metadata + must bind to a later same-``host_id`` host gate sharing a qubit. + This catches runtime/compiler lowering that reorders hosted local + pulses after the operation they semantically prepare. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A ``TickCircuit`` with no detector/observable metadata attached; the caller supplies that. """ - import pecos - - sim_builder = ( - pecos.sim(program).classical(pecos.selene_engine()).quantum(pecos.stabilizer()).qubits(num_qubits).seed(seed) + chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) + tick_circuit = _replay_qis_trace_chunks_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) + _validate_trace_hosted_operations_if_requested( + tick_circuit, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + context="trace_guppy_into_tick_circuit", ) - chunks = list(sim_builder.capture_operation_trace()) + return tick_circuit - # Selene lowers QIS gates into per-chunk `lowered_quantum_ops` (the gate - # shape actually executed; e.g. cx -> RZZ + rotations). When any chunk is - # lowered we replay from those, but first reject a mixed/partially-lowered - # trace that would silently drop a chunk's raw gates (see - # `_reject_partially_lowered_trace`). - if any(chunk.get("lowered_quantum_ops") for chunk in chunks): - _reject_partially_lowered_trace(chunks) - return _replay_lowered_qis_trace_into_tick_circuit(chunks) - # No chunk was lowered: replay the uniformly-raw QIS operation stream. - operations: list[dict[str, Any]] = [] - for chunk in chunks: - operations.extend(list(chunk.get("operations", []))) - return _replay_qis_trace_into_tick_circuit(operations) +def _validate_trace_hosted_operations_if_requested( + tick_circuit: Any, + *, + require_hosted_operation_order: bool, + max_hosted_tick_separation: int | None, + context: str, +) -> None: + if not require_hosted_operation_order and max_hosted_tick_separation is None: + return + validate_hosted_operations( + tick_circuit, + max_tick_separation=max_hosted_tick_separation, + require_host_after_local=require_hosted_operation_order, + require_unique_host_id=True, + context=context, + ) def _generate_traced_surface_tick_circuit( @@ -792,6 +1483,13 @@ def _generate_traced_surface_tick_circuit( basis: str, *, ancilla_budget: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + runtime: object | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -807,19 +1505,62 @@ def _generate_traced_surface_tick_circuit( traced faithfully rather than silently substituting the default rotated patch of the same distance. """ - from pecos.guppy import get_num_qubits - from pecos.guppy.surface import generate_memory_experiment + tc, _ = _generate_traced_surface_tick_circuit_with_result_traces( + patch, + num_rounds, + basis, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + check_plan=check_plan, + runtime=runtime, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + ) + return tc + + +def _generate_traced_surface_tick_circuit_with_result_traces( + patch: SurfacePatch, + num_rounds: int, + basis: str, + *, + ancilla_budget: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + runtime: object | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, +) -> tuple[Any, list[dict[str, Any]]]: + """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits program = generate_memory_experiment( patch, num_rounds, basis, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) - return trace_guppy_into_tick_circuit( + return trace_guppy_into_tick_circuit_with_result_traces( program, - get_num_qubits(patch=patch, ancilla_budget=ancilla_budget), + get_num_qubits( + patch=patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + ), seed=0, + runtime=runtime, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -830,18 +1571,44 @@ def _build_surface_tick_circuit_for_native_model( *, ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" - from pecos.qec.surface.circuit_builder import ( - _extract_measurement_order, - generate_tick_circuit_from_patch, - ) + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch + if twirl is not None: + twirl.validate_runtime_supported() + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="surface native TickCircuit generation", + ) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) + if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): + msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" + raise ValueError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, num_rounds, basis, ancilla_budget=ancilla_budget, + add_typed_annotations=False, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) if circuit_source == "abstract": @@ -851,43 +1618,49 @@ def _build_surface_tick_circuit_for_native_model( msg = f"Unknown circuit_source {circuit_source!r}" raise ValueError(msg) - traced_tc = _generate_traced_surface_tick_circuit( + if twirl is not None: + raise ValueError(_twirl_traced_qis_rejection_message()) + + traced_tc, result_traces = _generate_traced_surface_tick_circuit_with_result_traces( patch, num_rounds, basis, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + runtime=runtime, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) - # Coarse sanity check: the traced and abstract circuits must agree on the - # sequence of *measured qubit indices*. This catches gross drift (a dropped - # or added measurement, a wrong-qubit measurement, a different schedule - # shape). It is NOT an identity-level check: `_extract_measurement_order` - # returns physical qubit indices, and under ancilla reuse the same physical - # qubit appears in many measurements -- so two different stabilizer - # orderings can produce an identical qubit-index sequence and pass here. - # There is no independent stabilizer-identity oracle in the stack today: - # the detector/observable record offsets are the production binding (not a - # validator), and the byte-identical traced-vs-traced DEM regression shares - # the same shared batching policy on both sides (so it cannot catch a - # policy bug). The current safeguards against identity drift are the shared - # `batched_stabilizers` source-of-truth and the source-level CX-emission - # pins; a true identity check here would need stabilizer provenance the - # replayed TickCircuit does not currently carry (future work). - traced_measurement_order = _extract_measurement_order(traced_tc) - abstract_measurement_order = _extract_measurement_order(abstract_tc) - if traced_measurement_order != abstract_measurement_order: - msg = ( - "Traced and abstract surface circuits disagree on the measured-qubit " - "sequence (a dropped/added/wrong-qubit measurement or a different " - "schedule shape); refusing to build a native DEM/sampler from a " - "circuit that does not match the abstract detector/observable metadata" - ) - raise ValueError(msg) - _copy_surface_tick_circuit_metadata(abstract_tc, traced_tc) + measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) + _validate_result_tag_remap_against_traced_measurements( + traced_tc, + measurement_index_remap, + expected_measurements=int(abstract_tc.get_meta("num_measurements")), + ) + _copy_surface_tick_circuit_metadata( + abstract_tc, + traced_tc, + measurement_index_remap=measurement_index_remap, + ) + traced_tc.set_meta("surface_metadata_record_binding", "runtime_result_tags") + traced_tc.set_meta("circuit_source", circuit_source) return traced_tc +def _pauli_masks_as_int64(pauli_masks: Any) -> NDArray[np.int64]: + """Return Pauli-mask input in the integer dtype accepted by Rust bindings.""" + masks_arr = np.asarray(pauli_masks) + if not np.issubdtype(masks_arr.dtype, np.integer): + msg = "pauli_masks must be an integer array with values 0=I, 1=X, 2=Y, 3=Z" + raise TypeError(msg) + return np.asarray(masks_arr, dtype=np.int64) + + def build_memory_circuit( *, rounds: int, @@ -896,6 +1669,14 @@ def build_memory_circuit( basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -912,6 +1693,25 @@ def build_memory_circuit( ancilla_budget: Optional cap on simultaneously live ancillas. circuit_source: ``"abstract"`` for the native surface builder or ``"traced_qis"`` for the lowered traced QIS gate stream. + runtime: Optional Selene runtime selector/plugin used when + ``circuit_source="traced_qis"``. + twirl: Optional Pauli-frame randomization layout. Currently supported + only with ``circuit_source="abstract"``; traced-QIS twirl is + rejected because a runtime trace would bake one sampled mask into + the circuit and can lose canonical result-id provenance. + interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy used + for traced-QIS Guppy generation. This emits PECOS runtime barrier + helpers between selected data-prefix pulses and their host + SZZ/SZZdg operations. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after trace replay. A + hosted local gate must appear before its same-``host_id`` host. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -924,8 +1724,8 @@ def build_memory_circuit( """ from pecos.qec.surface.patch import SurfacePatch - if rounds < 1: - msg = f"rounds must be >= 1, got {rounds}" + if rounds < 0: + msg = f"rounds must be >= 0, got {rounds}" raise ValueError(msg) if patch is None: if distance is None: @@ -942,6 +1742,14 @@ def build_memory_circuit( basis, ancilla_budget=ancilla_budget, circuit_source=circuit_source, + runtime=runtime, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -977,31 +1785,210 @@ def _uses_dedicated_idle_noise( p_idle: float | None, t1: float | None, t2: float | None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, + p_idle_x_linear_rate: float | None = None, + p_idle_y_linear_rate: float | None = None, + p_idle_z_linear_rate: float | None = None, + p_idle_x_quadratic_rate: float | None = None, + p_idle_y_quadratic_rate: float | None = None, + p_idle_z_quadratic_rate: float | None = None, + p_idle_quadratic_sine_rate: float | None = None, + p_idle_x_quadratic_sine_rate: float | None = None, + p_idle_y_quadratic_sine_rate: float | None = None, + p_idle_z_quadratic_sine_rate: float | None = None, ) -> bool: """Return True when noise parameters require explicit idle locations.""" - return (p_idle is not None and p_idle > 0.0) or (t1 is not None and t2 is not None) + return ( + (p_idle is not None and p_idle > 0.0) + or (t1 is not None and t2 is not None) + or (p_idle_linear_rate is not None and p_idle_linear_rate > 0.0) + or (p_idle_quadratic_rate is not None and p_idle_quadratic_rate != 0.0) + or any( + rate is not None and rate > 0.0 + for rate in ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) + ) + ) def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: """Return True when this noise model requires explicit idle locations.""" - return _uses_dedicated_idle_noise(p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2) + return _uses_dedicated_idle_noise( + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, + p_idle_linear_rate=noise.p_idle_linear_rate, + p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + p_idle_x_linear_rate=noise.p_idle_x_linear_rate, + p_idle_y_linear_rate=noise.p_idle_y_linear_rate, + p_idle_z_linear_rate=noise.p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, + ) -@cache -def _cached_surface_native_topology( +def _reject_szz_unlowered_physical_noise( + noise: NoiseModel, + interaction_basis: str, + circuit_source: Literal["abstract", "traced_qis"], +) -> None: + """Reject SZZ surface DEM noise without well-defined gate locations.""" + if interaction_basis != "szz": + return + reasons: list[str] = [] + if _noise_uses_dedicated_idle_noise(noise) and circuit_source != "abstract": + reasons.append("dedicated idle noise with circuit_source='traced_qis'") + if not reasons: + return + joined = ", ".join(reasons) + msg = ( + "interaction_basis='szz' surface DEM generation does not yet support " + f"{joined} because idle noise needs explicit post-flow idle locations; " + "use circuit_source='abstract' for dedicated idle noise" + ) + raise ValueError(msg) + + +def _use_szz_physical_prefixes( + noise: NoiseModel, + interaction_basis: str, + circuit_source: Literal["abstract", "traced_qis"], +) -> bool: + return ( + interaction_basis == "szz" + and circuit_source == "abstract" + and (noise.p1 > 0.0 or _noise_uses_dedicated_idle_noise(noise)) + ) + + +def _szz_z_frame_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: + """Return virtual-Z frame p1 overrides for the staged SZZ device model. + + The current SZZ surface basis treats Z/SZ/SZdg frame updates as noiseless + virtual operations. That device-model assumption is keyed from + ``interaction_basis == "szz"`` in the staged API, so CX-vs-SZZ p1 location + comparisons include this free-Z modeling choice as well as gate basis + differences. + """ + if not topology.z_frame_gate_p1_free: + return None + return {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} + + +def _with_noise_compat( + builder: Any, + noise: NoiseModel, + *, + p1_gate_rates: Mapping[str, float] | None = None, +) -> Any: + """Call Rust ``with_noise`` using the richest signature this binding supports.""" + noise_kwargs = { + "p_idle": noise.p_idle, + "t1": noise.t1, + "t2": noise.t2, + "p_idle_linear_rate": noise.p_idle_linear_rate, + "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, + "p1_weights": _p1_weights_dict(noise.p1_weights), + "p2_weights": _p2_weights_dict(noise.p2_weights), + } + if p1_gate_rates is not None: + noise_kwargs["p1_gate_rates"] = {str(gate): float(rate) for gate, rate in p1_gate_rates.items()} + p2_gate_rates = _p2_gate_rates_dict(noise) + if p2_gate_rates is not None: + noise_kwargs["p2_gate_rates"] = p2_gate_rates + if noise.p2_replacement_approximation is not None: + noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation + + try: + return builder.with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + **noise_kwargs, + ) + except TypeError as exc: + unsupported = { + key: value for key, value in noise_kwargs.items() if key not in {"p_idle", "t1", "t2"} and value is not None + } + if unsupported: + msg = ( + "This pecos_rslib build does not support the requested advanced " + f"surface noise options: {sorted(unsupported)}" + ) + raise TypeError(msg) from exc + return builder.with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, + ) + + +def _surface_native_topology( patch_key: tuple[int, int, str, bool], num_rounds: int, basis: str, ancilla_budget: int | None, circuit_source: Literal["abstract", "traced_qis"], include_idle_gates: bool, + *, + runtime: object | None = None, + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: - """Cache topology-only native analysis shared across noise parameters.""" + """Build topology-only native analysis shared across noise parameters.""" import json - from pecos.qec import DagFaultAnalyzer - from pecos.qec.surface.circuit_builder import _extract_measurement_order + from pecos.qec.surface.circuit_builder import ( + _build_canonical_dem_influence_map, + _extract_measurement_order, + _metadata_record_offsets, + _metadata_uses_record_offsets, + normalize_traced_qis_tick_circuit, + ) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + require_current_surface_check_plan_renderer( + resolved_plan, + context="surface native topology construction", + ) + interaction_basis = resolved_plan.interaction_basis patch = _cached_surface_patch(patch_key) tc = _build_surface_tick_circuit_for_native_model( patch, @@ -1009,7 +1996,21 @@ def _cached_surface_native_topology( basis, ancilla_budget=ancilla_budget, circuit_source=circuit_source, + runtime=runtime, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) + if circuit_source == "traced_qis": + # Keep this surface helper aligned with DetectorErrorModel.from_guppy: + # traced QIS emits parameterized Clifford rotations, while DEM + # replacement-branch approximations operate on named Clifford gates. + normalize_traced_qis_tick_circuit(tc, context="surface traced-QIS native topology") if include_idle_gates: # Insert idle gates only when the requested noise model includes a # dedicated idle channel. Otherwise inserted idle gates receive ordinary @@ -1017,22 +2018,92 @@ def _cached_surface_native_topology( tc.fill_idle_gates() dag = tc.to_dag_circuit() - analyzer = DagFaultAnalyzer(dag) - influence_map = analyzer.build_influence_map() + influence_map = _build_canonical_dem_influence_map(dag) detectors_json = tc.get_meta("detectors") or "[]" observables_json = tc.get_meta("observables") or "[]" - measurement_order = tuple(_extract_measurement_order(tc)) + measurement_order = ( + tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () + ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) + det_records = ( + [_metadata_record_offsets(detector, num_measurements) for detector in json.loads(detectors_json)] + if detectors_json + else [] + ) + obs_records = ( + [_metadata_record_offsets(observable, num_measurements) for observable in json.loads(observables_json)] + if observables_json + else [] + ) + + pauli_frame_lookup = None + num_pauli_sites = 0 + if twirl is not None: + from pecos_rslib.qec import PauliFrameLookup + + pauli_frame_lookup = PauliFrameLookup.from_circuit(dag, det_records, obs_records) + num_pauli_sites = pauli_frame_lookup.num_pauli_sites return _CachedNativeSurfaceTopology( + dag_circuit=dag, influence_map=influence_map, + szz_physical_prefixes=szz_physical_prefixes, + # Staged SZZ device model: Z/SZ/SZdg frame updates are virtual and + # receive no p1 noise. Keep CX-vs-SZZ p1 location comparisons scoped to + # that asymmetric device assumption. + z_frame_gate_p1_free=interaction_basis == "szz", + pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, observables_json=observables_json, measurement_order=measurement_order, num_measurements=num_measurements, - num_detectors=len(json.loads(detectors_json)) if detectors_json else 0, - num_observables=len(json.loads(observables_json)) if observables_json else 0, + num_detectors=len(det_records), + num_observables=len(obs_records), + num_pauli_sites=num_pauli_sites, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, + ) + + +@cache +def _cached_surface_native_topology( + patch_key: tuple[int, int, str, bool], + num_rounds: int, + basis: str, + ancilla_budget: int | None, + circuit_source: Literal["abstract", "traced_qis"], + include_idle_gates: bool, + *, + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + szz_physical_prefixes: bool = False, + resolved_check_plan_hash: str = "", + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, +) -> _CachedNativeSurfaceTopology: + """Cache topology-only native analysis shared across noise parameters.""" + _ = resolved_check_plan_hash + return _surface_native_topology( + patch_key, + num_rounds, + basis, + ancilla_budget, + circuit_source, + include_idle_gates, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=check_plan, + szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1041,20 +2112,44 @@ def _dem_string_from_cached_surface_topology( noise: NoiseModel, *, decompose_errors: bool, + dem_decomposition: NativeDemDecomposition = "source_graphlike", ) -> str: """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder + builder = _with_noise_compat( + DemBuilder(topology.influence_map), + noise, + p1_gate_rates=_szz_z_frame_p1_gate_rates(topology), + ) + if hasattr(builder, "with_exact_branch_replay_circuit"): + builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) + + builder = builder.with_num_measurements(topology.num_measurements) + if topology.measurement_order: + builder = builder.with_measurement_order(list(topology.measurement_order)) dem = ( - DemBuilder(topology.influence_map) - .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2) - .with_num_measurements(topology.num_measurements) - .with_measurement_order(list(topology.measurement_order)) - .with_detectors_json(topology.detectors_json) - .with_observables_json(topology.observables_json) + builder.with_detectors_json(topology.detectors_json) + .with_observables_json( + topology.observables_json, + ) .build_with_source_tracking() ) - return dem.to_string_decomposed() if decompose_errors else dem.to_string() + if not decompose_errors: + return dem.to_string() + if dem_decomposition == "source_graphlike": + source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) + if source_graphlike is not None: + return source_graphlike() + return dem.to_string_decomposed() + if dem_decomposition == "terminal_graphlike": + terminal_graphlike = getattr(dem, "to_string_terminal_graphlike_decomposed", None) + if terminal_graphlike is None: + msg = "This pecos_rslib build does not support terminal graphlike DEM decomposition" + raise RuntimeError(msg) + return terminal_graphlike() + msg = f"Unknown native DEM decomposition mode {dem_decomposition!r}" + raise ValueError(msg) @cache @@ -1065,16 +2160,63 @@ def _cached_surface_native_dem_string( ancilla_budget: int | None, circuit_source: Literal["abstract", "traced_qis"], p1: float, + p1_weights: tuple[tuple[str, float], ...] | None, p2: float, + p2_szz: float | None, + p2_szzdg: float | None, p_meas: float, p_prep: float, decompose_errors: bool, + dem_decomposition: NativeDemDecomposition = "source_graphlike", + p2_weights: tuple[tuple[str, float], ...] | None = None, + p2_replacement_approximation: str | None = None, p_idle: float | None = None, t1: float | None = None, t2: float | None = None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, + p_idle_x_linear_rate: float | None = None, + p_idle_y_linear_rate: float | None = None, + p_idle_z_linear_rate: float | None = None, + p_idle_x_quadratic_rate: float | None = None, + p_idle_y_quadratic_rate: float | None = None, + p_idle_z_quadratic_rate: float | None = None, + p_idle_quadratic_sine_rate: float | None = None, + p_idle_x_quadratic_sine_rate: float | None = None, + p_idle_y_quadratic_sine_rate: float | None = None, + p_idle_z_quadratic_sine_rate: float | None = None, + twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", + check_plan: str | None = None, + resolved_check_plan_hash: str = "", + clifford_frame_policy: str | None = None, + *, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> 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) + _ = resolved_check_plan_hash + include_idle_gates = _uses_dedicated_idle_noise( + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + szz_physical_prefixes = ( + interaction_basis == "szz" and circuit_source == "abstract" and (p1 > 0.0 or include_idle_gates) + ) topology = _cached_surface_native_topology( patch_key, num_rounds, @@ -1082,11 +2224,46 @@ def _cached_surface_native_dem_string( ancilla_budget, circuit_source, include_idle_gates, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=check_plan, + szz_physical_prefixes=szz_physical_prefixes, + resolved_check_plan_hash=resolved_check_plan_hash, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) return _dem_string_from_cached_surface_topology( topology, - NoiseModel(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, t1=t1, t2=t2), + NoiseModel( + p1=p1, + p1_weights=p1_weights, + p2=p2, + p2_szz=p2_szz, + p2_szzdg=p2_szzdg, + p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, + p_meas=p_meas, + p_prep=p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ), decompose_errors=decompose_errors, + dem_decomposition=dem_decomposition, ) @@ -1109,7 +2286,7 @@ def _build_native_sampler_from_cached_surface_topology( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", ) -> NativeSampler: """Construct a native sampler from cached topology-only analysis.""" - from pecos.qec import DemSampler, ParsedDem + from pecos.qec import ParsedDem if sampling_model == "dem": dem_str = _dem_string_from_cached_surface_topology( @@ -1119,22 +2296,20 @@ def _build_native_sampler_from_cached_surface_topology( ) sampler = ParsedDem.from_string(dem_str).to_dem_sampler() elif sampling_model in ("influence_dem", "mnm"): - import json - - det_records = [d["records"] for d in json.loads(topology.detectors_json)] - obs_records = [o["records"] for o in json.loads(topology.observables_json)] if topology.observables_json else [] - sampler = DemSampler.with_detectors( - topology.influence_map, - det_records, - obs_records, - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, + from pecos.qec import DemSamplerBuilder + + sampler_builder = ( + _with_noise_compat( + DemSamplerBuilder(topology.influence_map), + noise, + p1_gate_rates=_szz_z_frame_p1_gate_rates(topology), + ) + .with_detectors_json(topology.detectors_json) + .with_observables_json(topology.observables_json) ) + if topology.measurement_order: + sampler_builder = sampler_builder.with_measurement_order(list(topology.measurement_order)) + sampler = sampler_builder.build() # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" else: @@ -1147,7 +2322,13 @@ def _build_native_sampler_from_cached_surface_topology( observables_json=topology.observables_json, num_detectors=topology.num_detectors, num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, + interaction_basis=topology.interaction_basis, + check_plan=topology.check_plan, + resolved_check_plan=topology.resolved_check_plan, + resolved_check_plan_hash=topology.resolved_check_plan_hash, ) @@ -1158,8 +2339,17 @@ def generate_circuit_level_dem_from_builder( basis: str = "Z", *, decompose_errors: bool = False, + dem_decomposition: NativeDemDecomposition = "source_graphlike", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -1177,9 +2367,17 @@ def generate_circuit_level_dem_from_builder( num_rounds: Number of syndrome extraction rounds noise: Noise model parameters basis: Memory basis ('X' or 'Z') - decompose_errors: If True, return PECOS's native decomposed DEM - representation, which is more appropriate for graph-based - decoders like PyMatching. + decompose_errors: If True, return PECOS's native graphlike-decomposed + DEM representation for graph decoders such as PyMatching. The + decomposition is a lossy hyperedge-to-edge projection; it preserves + correlated mechanism metadata with ``^`` separators where available, + but it is not an exact raw DEM serialization. + dem_decomposition: Which native graphlike projection to use when + ``decompose_errors=True``. ``"source_graphlike"`` preserves the + existing source-informed decomposition. ``"terminal_graphlike"`` + groups raw mechanisms first, then pairs only detector terminals + present in each raw effect by coordinate distance. Both modes are + decoder-facing approximations of raw hyperedge mechanisms. ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, the native DEM is built from the same batched ancilla-reuse circuit family used by Guppy. @@ -1188,6 +2386,33 @@ def generate_circuit_level_dem_from_builder( ``"traced_qis"`` traces the lowered ideal Selene/QIS gate stream and replays that exact gate list into a TickCircuit before running native PECOS fault analysis. + runtime: Optional Selene runtime selector/plugin used when + ``circuit_source="traced_qis"``. Custom runtime topologies are not + kept in PECOS's in-process topology cache because plugin objects + can carry private mutable state. + twirl: Optional Pauli-frame randomization layout. Canonical Guppy + frame-output mode is normalized to the same abstract raw lookup + and DEM topology. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. The staged SZZ plan currently assumes a virtual-Z device + model: Z/SZ/SZdg frame updates are p1-free. That is a device + assumption keyed from the resolved plan, not a general claim about + CX hardware. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native SZZ generation. For ``circuit_source="traced_qis"``, + the Guppy program is generated from the same concrete deformed + checks before runtime result tags are bound to surface metadata. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. This is intended for source-local pulses that semantically + prepare a later host operation, such as SZZ/SZZdg data prefixes. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: DEM string in standard format @@ -1200,7 +2425,69 @@ def generate_circuit_level_dem_from_builder( >>> dem = generate_circuit_level_dem_from_builder(patch, num_rounds=3, noise=noise) """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) + + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis + _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) patch_key = _surface_patch_cache_key(patch) + include_idle_gates = _noise_uses_dedicated_idle_noise(noise) + szz_physical_prefixes = _use_szz_physical_prefixes(noise, interaction_basis, circuit_source) + if runtime is not None: + topology = _surface_native_topology( + patch_key, + num_rounds, + basis.upper(), + ancilla_budget, + circuit_source, + include_idle_gates, + runtime=runtime, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + ) + return _dem_string_from_cached_surface_topology( + topology, + noise, + decompose_errors=decompose_errors, + dem_decomposition=dem_decomposition, + ) + + cache_kwargs = { + "p2_weights": noise.p2_weights, + "p2_replacement_approximation": noise.p2_replacement_approximation, + "p_idle": noise.p_idle, + "t1": noise.t1, + "t2": noise.t2, + "p_idle_linear_rate": noise.p_idle_linear_rate, + "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, + "twirl": twirl, + "interaction_basis": interaction_basis, + "check_plan": resolved_plan.plan_id, + "resolved_check_plan_hash": resolved_plan.resolved_hash, + "clifford_frame_policy": clifford_frame_policy, + "szz_runtime_barriers": szz_runtime_barriers, + "require_hosted_operation_order": require_hosted_operation_order, + "max_hosted_tick_separation": max_hosted_tick_separation, + } + if dem_decomposition != "source_graphlike": + cache_kwargs["dem_decomposition"] = dem_decomposition + return _cached_surface_native_dem_string( patch_key, num_rounds, @@ -1208,13 +2495,14 @@ def generate_circuit_level_dem_from_builder( ancilla_budget, circuit_source, noise.p1, + noise.p1_weights, noise.p2, + noise.p2_szz, + noise.p2_szzdg, noise.p_meas, noise.p_prep, decompose_errors=decompose_errors, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, + **cache_kwargs, ) @@ -1582,6 +2870,8 @@ def __init__( noise: NoiseModel | None = None, decoder_type: Literal[ "pymatching", + "pymatching_correlated", + "pymatching_uncorrelated", "fusion_blossom", "bp_osd", "bp_lsd", @@ -1590,9 +2880,10 @@ def __init__( ] = "pymatching", *, use_circuit_level_dem: bool = True, - circuit_level_dem_mode: Literal["native_full", "native_decomposed"] = "native_full", + circuit_level_dem_mode: CircuitLevelDemMode = "native_full", circuit_level_dem_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, + interaction_basis: str = "cx", ) -> None: """Initialize decoder from surface code patch. @@ -1601,7 +2892,13 @@ def __init__( num_rounds: Number of syndrome extraction rounds noise: Noise model for edge weights (defaults to uniform) decoder_type: Decoder backend to use: - - "pymatching": Fast C++ MWPM decoder (default) + - "pymatching": Fast C++ MWPM decoder (default). For + decomposed circuit-level DEMs, this enables PyMatching's + DEM-correlation metadata when available. + - "pymatching_correlated": Explicit alias for the circuit-level + correlated PyMatching path. + - "pymatching_uncorrelated": Plain graphlike PyMatching path, + useful for A/B diagnostics. - "fusion_blossom": Pure Rust MWPM decoder - "bp_osd": Belief Propagation + OSD - "bp_lsd": Belief Propagation + LSD @@ -1615,8 +2912,14 @@ def __init__( circuit_level_dem_mode: Which PECOS-native DEM representation to use when circuit-level DEMs are enabled. ``"native_full"`` preserves the current non-decomposed DEM output. ``"native_decomposed"`` - returns PECOS's graphlike decomposed DEM output, which is often - a better fit for graph decoders such as PyMatching. + returns the source-informed graphlike projection for graph + decoders. + ``"native_terminal_graphlike"`` first groups raw mechanisms, + then projects each mechanism onto graphlike terminal components. + Decomposed modes are lossy decoder-facing approximations of + hyperedge correlations, not exact raw DEMs. Correlated graph + decoding can use some preserved ``^`` metadata, but raw-DEM + decoders should use ``"native_full"``. circuit_level_dem_source: Which ideal circuit to analyze when building native circuit-level DEMs. ``"abstract"`` uses the high-level surface TickCircuit, while ``"traced_qis"`` traces @@ -1625,15 +2928,28 @@ def __init__( the native circuit-level DEM path. When provided, the decoder builds its DEM from the corresponding batched ancilla-reuse circuit instead of the default dedicated-ancilla circuit. + interaction_basis: Surface-memory two-qubit interaction basis, + ``"cx"`` or ``"szz"``. The staged ``"szz"`` path currently + treats Z/SZ/SZdg frame updates as p1-free virtual operations. """ + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + self.patch = patch self.num_rounds = num_rounds self.noise = noise or NoiseModel(p2=0.01, p_meas=0.01) self.decoder_type = DecoderType(decoder_type) self.use_circuit_level_dem = use_circuit_level_dem + if circuit_level_dem_mode not in { + "native_full", + "native_decomposed", + "native_terminal_graphlike", + }: + msg = f"Unknown circuit_level_dem_mode {circuit_level_dem_mode!r}" + raise ValueError(msg) self.circuit_level_dem_mode = circuit_level_dem_mode self.circuit_level_dem_source = circuit_level_dem_source self.ancilla_budget = ancilla_budget + self.interaction_basis = _normalize_interaction_basis(interaction_basis) # Lazily create decoders self._x_decoder = None @@ -1662,14 +2978,19 @@ def _get_circuit_level_dem(self, basis: str) -> str: Returns: DEM string in Stim format """ + dem_decomposition: NativeDemDecomposition = ( + "terminal_graphlike" if self.circuit_level_dem_mode == "native_terminal_graphlike" else "source_graphlike" + ) dem = generate_circuit_level_dem_from_builder( self.patch, self.num_rounds, self.noise, basis=basis, - decompose_errors=self.circuit_level_dem_mode == "native_decomposed", + decompose_errors=self.circuit_level_dem_mode != "native_full", + dem_decomposition=dem_decomposition, circuit_source=self.circuit_level_dem_source, ancilla_budget=self.ancilla_budget, + interaction_basis=self.interaction_basis, ) if basis.upper() == "Z": self._z_dem = dem @@ -1713,10 +3034,7 @@ def _get_z_decoder(self) -> Any: """Get or create decoder for Z-basis memory (decodes Z syndromes for X errors).""" if self._z_decoder is None: # For PyMatching and Tesseract with circuit-level DEMs, use DEM directly - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: self._z_decoder = self._create_decoder_from_dem("Z") else: self._z_decoder = self._create_decoder(self._get_z_check_matrix()) @@ -1734,8 +3052,12 @@ def _create_decoder(self, H: NDArray[np.uint8]) -> Any: data_weight = self._compute_weight(p_data) meas_weight = self._compute_weight(p_meas) - if self.decoder_type == DecoderType.PYMATCHING: - from pecos_rslib.decoders import CheckMatrix, PyMatchingDecoder + if self.decoder_type in PYMATCHING_DECODER_TYPES: + from pecos.decoders import CheckMatrix, PyMatchingDecoder + + if self.decoder_type == DecoderType.PYMATCHING_CORRELATED: + msg = "pymatching_correlated requires circuit-level DEM decoding" + raise ValueError(msg) weights = [data_weight] * num_data check_matrix = CheckMatrix.from_dense(H.tolist()).with_weights(weights) @@ -1749,7 +3071,7 @@ def _create_decoder(self, H: NDArray[np.uint8]) -> Any: ) if self.decoder_type == DecoderType.FUSION_BLOSSOM: - from pecos_rslib.decoders import FusionBlossomDecoder + from pecos.decoders import FusionBlossomDecoder # FusionBlossom uses check matrix directly # For multi-round, we need to construct the space-time graph manually @@ -1800,13 +3122,22 @@ def _create_decoder_from_dem(self, basis: str) -> Any: else: self._x_dem = dem - if self.decoder_type == DecoderType.PYMATCHING: - from pecos_rslib.decoders import PyMatchingDecoder + if self.decoder_type in PYMATCHING_DECODER_TYPES: + from pecos.decoders import PyMatchingDecoder - return PyMatchingDecoder.from_dem(dem) + if self.circuit_level_dem_mode == "native_full": + if self.decoder_type == DecoderType.PYMATCHING_CORRELATED: + msg = "pymatching_correlated requires a decomposed circuit-level DEM mode" + raise ValueError(msg) + return PyMatchingDecoder.from_dem(dem) + + if self.decoder_type == DecoderType.PYMATCHING_UNCORRELATED: + return PyMatchingDecoder.from_dem(dem) + + return PyMatchingDecoder.from_dem_with_correlations(dem, enable_correlations=True) if self.decoder_type == DecoderType.TESSERACT: - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder # Tesseract's remove_zero_probability_errors() doesn't handle # DEM_LOGICAL_OBSERVABLE instructions. Filter them out - the @@ -1824,7 +3155,7 @@ def _create_fusion_blossom_spacetime( meas_weight: float, ) -> Any: """Create FusionBlossom decoder with space-time matching graph.""" - from pecos_rslib.decoders import FusionBlossomDecoder + from pecos.decoders import FusionBlossomDecoder num_stab = H.shape[0] num_data = H.shape[1] @@ -1884,12 +3215,12 @@ def _create_ldpc_decoder( p_data: float, ) -> Any: """Create LDPC decoder (BP+OSD, BP+LSD, or UnionFind).""" - from pecos_rslib.decoders import SparseMatrix + from pecos.decoders import SparseMatrix sparse_H = SparseMatrix(H.tolist()) if self.decoder_type == DecoderType.BP_OSD: - from pecos_rslib.decoders import BpOsdBuilder + from pecos.decoders import BpOsdBuilder return ( BpOsdBuilder(sparse_H, error_rate=p_data) @@ -1901,12 +3232,12 @@ def _create_ldpc_decoder( ) if self.decoder_type == DecoderType.BP_LSD: - from pecos_rslib.decoders import BpLsdBuilder + from pecos.decoders import BpLsdBuilder return BpLsdBuilder(sparse_H, error_rate=p_data).max_iter(100).bp_method("product_sum").lsd_order(0).build() if self.decoder_type == DecoderType.UNION_FIND: - from pecos_rslib.decoders import UnionFindBuilder + from pecos.decoders import UnionFindBuilder return UnionFindBuilder(sparse_H).method("inversion").build() @@ -1920,7 +3251,7 @@ def _create_tesseract_decoder( _p_meas: float, ) -> Any: """Create Tesseract decoder from check matrix by generating DEM.""" - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder # Determine stabilizer type based on check matrix shape z_check = self._get_z_check_matrix() @@ -1986,10 +3317,7 @@ def _get_x_decoder(self) -> Any: """Get or create decoder for X-basis memory (decodes X syndromes for Z errors).""" if self._x_decoder is None: # For PyMatching and Tesseract with circuit-level DEMs, use DEM directly - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: self._x_decoder = self._create_decoder_from_dem("X") else: self._x_decoder = self._create_decoder(self._get_x_check_matrix()) @@ -1999,6 +3327,8 @@ def _is_mwpm_decoder(self) -> bool: """Check if using an MWPM or Tesseract decoder (vs LDPC).""" return self.decoder_type in ( DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, DecoderType.FUSION_BLOSSOM, DecoderType.TESSERACT, ) @@ -2125,20 +3455,24 @@ def _compute_dem_detection_events_z( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synx: NDArray[np.uint8] | None = None, ) -> NDArray[np.uint8]: """Compute full detection events for Z-basis DEM-based decoding. The circuit-level DEM defines detectors in this order: - 1. X stabilizer detectors for rounds 1..num_rounds-1 - (X stabs are non-deterministic at round 0 for Z-basis) + 1. X stabilizer detectors for rounds 0..num_rounds-1 + (round 0 compares against the init X-syndrome baseline when present) 2. Z stabilizer detectors for rounds 0..num_rounds-1 (round 0 is deterministic for Z-basis) - 3. Final round detectors: last Z syndrome vs final data parity + 3. Final detectors: last known Z syndrome vs final data parity + (for r=0, the known Z syndrome is the deterministic prep sign) Args: synx_list: X syndrome arrays, one per round synz_list: Z syndrome arrays, one per round final: Final data qubit measurements + init_synx: Initial X-syndrome baseline measured during logical prep Returns: Detection events array matching the DEM detector ordering @@ -2146,22 +3480,38 @@ def _compute_dem_detection_events_z( geom = self.patch.geometry synx = np.array(synx_list, dtype=np.uint8) synz = np.array(synz_list, dtype=np.uint8) + if self.num_rounds > 0 and init_synx is None: + msg = ( + "Z-basis circuit-level DEM decoding requires init_synx, the prep-baseline " + "X syndrome measured before counted syndrome-extraction rounds." + ) + raise ValueError(msg) events: list[int] = [] - # 1. X stabilizer detection events (rounds 1 to num_rounds-1) - for r in range(1, self.num_rounds): - events.extend((synx[r] ^ synx[r - 1]).tolist()) + if self.num_rounds > 0: + if init_synx is None: + msg = "init_synx is required for Z-basis circuit-level DEM decoding" + raise ValueError(msg) + init_synx_array = np.array(init_synx, dtype=np.uint8) + if init_synx_array.shape != synx[0].shape: + msg = f"init_synx has shape {init_synx_array.shape}, expected {synx[0].shape}" + raise ValueError(msg) + + # 1. X stabilizer detection events + events.extend((synx[0] ^ init_synx_array).tolist()) + for r in range(1, self.num_rounds): + events.extend((synx[r] ^ synx[r - 1]).tolist()) - # 2. Z stabilizer detection events (all rounds) - events.extend(synz[0].tolist()) # round 0: compare to expected 0 - for r in range(1, self.num_rounds): - events.extend((synz[r] ^ synz[r - 1]).tolist()) + # 2. Z stabilizer detection events (all rounds) + events.extend(synz[0].tolist()) # round 0: compare to expected 0 + for r in range(1, self.num_rounds): + events.extend((synz[r] ^ synz[r - 1]).tolist()) - # 3. Final round: parity of final data on each Z stabilizer XOR last syndrome + # 3. Final readout: final Z parity XOR the last known Z syndrome. for stab in geom.z_stabilizers: data_parity = sum(int(final[q]) for q in stab.data_qubits) % 2 - last_syn = int(synz[-1][stab.index]) + last_syn = int(synz[-1][stab.index]) if self.num_rounds > 0 else 0 events.append((data_parity ^ last_syn) & 1) return np.array(events, dtype=np.uint8) @@ -2171,20 +3521,24 @@ def _compute_dem_detection_events_x( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synz: NDArray[np.uint8] | None = None, ) -> NDArray[np.uint8]: """Compute full detection events for X-basis DEM-based decoding. The circuit-level DEM defines detectors in this order: 1. X stabilizer detectors for rounds 0..num_rounds-1 (X stabs are deterministic at round 0 for X-basis) - 2. Z stabilizer detectors for rounds 1..num_rounds-1 - (Z stabs are non-deterministic at round 0 for X-basis) - 3. Final round detectors: last X syndrome vs final data parity + 2. Z stabilizer detectors for rounds 0..num_rounds-1 + (round 0 compares against the init Z-syndrome baseline when present) + 3. Final detectors: last known X syndrome vs final data parity + (for r=0, the known X syndrome is the deterministic prep sign) Args: synx_list: X syndrome arrays, one per round synz_list: Z syndrome arrays, one per round final: Final data qubit measurements + init_synz: Initial Z-syndrome baseline measured during logical prep Returns: Detection events array matching the DEM detector ordering @@ -2192,22 +3546,38 @@ def _compute_dem_detection_events_x( geom = self.patch.geometry synx = np.array(synx_list, dtype=np.uint8) synz = np.array(synz_list, dtype=np.uint8) + if self.num_rounds > 0 and init_synz is None: + msg = ( + "X-basis circuit-level DEM decoding requires init_synz, the prep-baseline " + "Z syndrome measured before counted syndrome-extraction rounds." + ) + raise ValueError(msg) events: list[int] = [] - # 1. X stabilizer detection events (all rounds) - events.extend(synx[0].tolist()) # round 0: compare to expected 0 - for r in range(1, self.num_rounds): - events.extend((synx[r] ^ synx[r - 1]).tolist()) + if self.num_rounds > 0: + if init_synz is None: + msg = "init_synz is required for X-basis circuit-level DEM decoding" + raise ValueError(msg) + init_synz_array = np.array(init_synz, dtype=np.uint8) + if init_synz_array.shape != synz[0].shape: + msg = f"init_synz has shape {init_synz_array.shape}, expected {synz[0].shape}" + raise ValueError(msg) + + # 1. X stabilizer detection events (all rounds) + events.extend(synx[0].tolist()) # round 0: compare to expected 0 + for r in range(1, self.num_rounds): + events.extend((synx[r] ^ synx[r - 1]).tolist()) - # 2. Z stabilizer detection events (rounds 1 to num_rounds-1) - for r in range(1, self.num_rounds): - events.extend((synz[r] ^ synz[r - 1]).tolist()) + # 2. Z stabilizer detection events + events.extend((synz[0] ^ init_synz_array).tolist()) + for r in range(1, self.num_rounds): + events.extend((synz[r] ^ synz[r - 1]).tolist()) - # 3. Final round: parity of final data on each X stabilizer XOR last syndrome + # 3. Final readout: final X parity XOR the last known X syndrome. for stab in geom.x_stabilizers: data_parity = sum(int(final[q]) for q in stab.data_qubits) % 2 - last_syn = int(synx[-1][stab.index]) + last_syn = int(synx[-1][stab.index]) if self.num_rounds > 0 else 0 events.append((data_parity ^ last_syn) & 1) return np.array(events, dtype=np.uint8) @@ -2217,6 +3587,8 @@ def decode_memory_z( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synx: NDArray[np.uint8] | None = None, ) -> tuple[bool, DecodingResult]: """Decode a Z-basis memory experiment. @@ -2238,6 +3610,8 @@ def decode_memory_z( synx_list: List of X syndrome arrays, one per round synz_list: List of Z syndrome arrays, one per round final: Final data qubit measurements + init_synx: Optional prep-baseline X syndrome for the random + stabilizer signs established before counted Z-memory rounds. Returns: (is_logical_error, decoding_result) @@ -2247,11 +3621,8 @@ def decode_memory_z( final_parity = sum(final[q] for q in logical_z_qubits) % 2 # DEM-based path: compute full detection events matching DEM detector order - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): - events = self._compute_dem_detection_events_z(synx_list, synz_list, final) + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: + events = self._compute_dem_detection_events_z(synx_list, synz_list, final, init_synx=init_synx) events_flat = events.ravel().astype(np.uint8) decoder = self._get_z_decoder() @@ -2312,6 +3683,8 @@ def decode_memory_x( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synz: NDArray[np.uint8] | None = None, ) -> tuple[bool, DecodingResult]: """Decode an X-basis memory experiment. @@ -2333,6 +3706,8 @@ def decode_memory_x( synx_list: List of X syndrome arrays, one per round synz_list: List of Z syndrome arrays, one per round final: Final data qubit measurements + init_synz: Optional prep-baseline Z syndrome for the random + stabilizer signs established before counted X-memory rounds. Returns: (is_logical_error, decoding_result) @@ -2342,11 +3717,8 @@ def decode_memory_x( final_parity = sum(final[q] for q in logical_x_qubits) % 2 # DEM-based path: compute full detection events matching DEM detector order - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): - events = self._compute_dem_detection_events_x(synx_list, synz_list, final) + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: + events = self._compute_dem_detection_events_x(synx_list, synz_list, final, init_synz=init_synz) events_flat = events.ravel().astype(np.uint8) decoder = self._get_x_decoder() @@ -2479,6 +3851,10 @@ class SimulationResult: raw_error_rate: Raw error rate (no decoding) decoded: Whether decoding was applied decoder_type: Decoder backend used (if decoded) + interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. + resolved_check_plan: Canonical resolved check-plan metadata. + resolved_check_plan_hash: SHA-256 hash of the resolved plan semantics. """ distance: int @@ -2491,6 +3867,10 @@ class SimulationResult: raw_error_rate: float decoded: bool decoder_type: str | None = None + interaction_basis: str = "cx" + check_plan: str = "cx_standard_v1" + resolved_check_plan: dict[str, Any] | None = None + resolved_check_plan_hash: str = "" def _memory_noise_model( @@ -2507,6 +3887,13 @@ def _memory_noise_model( return NoiseModel.uniform(p) +def _recommended_graphlike_decomposition_for_decoder(decoder_type: str) -> NativeDemDecomposition: + base = decoder_type.split(":", 1)[0] + if base in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: + return "terminal_graphlike" + return "source_graphlike" + + def surface_code_memory( *, distance: int = 3, @@ -2520,6 +3907,12 @@ def surface_code_memory( decode: bool = True, circuit_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -2537,10 +3930,26 @@ def surface_code_memory( rounds: Number of syndrome-extraction rounds. Defaults to ``distance``. basis: Memory basis, ``"Z"`` or ``"X"``. decoder_type: Decoder backend passed to ``SampleBatch.decode_count``. + PyMatching-family decoders use PECOS's terminal graphlike DEM + projection for this recommended workflow. seed: Optional sampler seed. decode: If false, report the raw observable-flip rate. circuit_source: ``"abstract"`` or ``"traced_qis"`` circuit source. ancilla_budget: Optional cap on simultaneously live ancillas. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -2554,6 +3963,8 @@ def surface_code_memory( from pecos.qec import ParsedDem from pecos.qec.surface.patch import SurfacePatch + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis if distance < 1: msg = f"distance must be >= 1, got {distance}" raise ValueError(msg) @@ -2561,8 +3972,8 @@ def surface_code_memory( msg = f"shots must be >= 0, got {shots}" raise ValueError(msg) num_rounds = distance if rounds is None else rounds - if num_rounds < 1: - msg = f"rounds must be >= 1, got {num_rounds}" + if num_rounds < 0: + msg = f"rounds must be >= 0, got {num_rounds}" raise ValueError(msg) noise_model = _memory_noise_model(physical_error_rate, noise_model) @@ -2573,8 +3984,15 @@ def surface_code_memory( noise=noise_model, basis=basis, decompose_errors=True, + dem_decomposition=_recommended_graphlike_decomposition_for_decoder(decoder_type), ancilla_budget=ancilla_budget, circuit_source=circuit_source, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) 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) @@ -2591,6 +4009,10 @@ def surface_code_memory( raw_error_rate=num_raw_errors / shots if shots else 0.0, decoded=decode, decoder_type=decoder_type if decode else None, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -2603,6 +4025,8 @@ def run_noisy_memory_experiment( *, decode: bool = True, decoder_type: str = "pymatching", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> SimulationResult: """Run a noisy surface code memory experiment with optional decoding. @@ -2620,6 +4044,11 @@ def run_noisy_memory_experiment( noise: Noise model parameters decode: If True, use decoding to correct errors decoder_type: Decoder backend (pymatching, fusion_blossom, bp_osd, etc.) + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: SimulationResult with error rate statistics @@ -2643,6 +4072,8 @@ def run_noisy_memory_experiment( from pecos.guppy.surface import get_num_qubits, make_surface_code from pecos.qec.surface import SurfacePatch + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis # Create patch and decoder patch = SurfacePatch.create(distance=distance) geom = patch.geometry @@ -2663,11 +4094,17 @@ def run_noisy_memory_experiment( num_rounds=num_rounds, noise=noise, decoder_type=dt, + interaction_basis=interaction_basis, ) # Build and compile circuit - num_qubits = get_num_qubits(distance) - prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis) + num_qubits = get_num_qubits(distance, interaction_basis=interaction_basis) + prog = make_surface_code( + distance=distance, + num_rounds=num_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) hugr_bytes = compile_guppy_to_hugr(prog) instance = build(hugr_bytes, name=f"surface_d{distance}") @@ -2742,6 +4179,10 @@ def run_noisy_memory_experiment( raw_error_rate=num_raw_errors / num_shots if num_shots > 0 else 0.0, decoded=decode, decoder_type=decoder_type if decode else None, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -2769,7 +4210,16 @@ class NativeSampler: observables_json: JSON string with observable definitions num_detectors: Number of detectors num_observables: Number of observables + pauli_frame_lookup: Optional PECOS lookup for Pauli-twirl mask composition + num_pauli_sites: Number of Pauli-twirl mask sites sampling_model: Which native sampling backend is active + dem_string: Optional graphlike-decomposed DEM string used to build the + sampler. Populated when the ``"dem"`` sampling model is selected. + interaction_basis: Surface-memory two-qubit interaction basis resolved + from ``check_plan``. + check_plan: Named surface check-plan preset. + resolved_check_plan: Canonical resolved check-plan metadata. + resolved_check_plan_hash: SHA-256 hash of the resolved plan semantics. """ sampler: Any @@ -2777,14 +4227,23 @@ class NativeSampler: observables_json: str num_detectors: int num_observables: int + pauli_frame_lookup: Any | None = None + num_pauli_sites: int = 0 sampling_model: Literal["dem", "influence_dem", "mnm"] = ( "dem" # "mnm" accepted for compat, mapped to "influence_dem" ) + dem_string: str | None = None + interaction_basis: str = "cx" + check_plan: str = "cx_standard_v1" + resolved_check_plan: dict[str, Any] | None = None + resolved_check_plan_hash: str = "" def sample( self, num_shots: int, seed: int | None = None, + *, + pauli_masks: Any | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Sample detection events and observable flips. @@ -2793,13 +4252,29 @@ def sample( Args: num_shots: Number of shots to sample seed: Optional random seed for reproducibility + pauli_masks: Optional integer array of shape + ``(num_shots, num_pauli_sites)`` with values 0=I, 1=X, 2=Y, + 3=Z. Requires ``build_native_sampler(..., + twirl=TwirlConfig())``. Returns: Tuple of (detection_events, observable_flips) as numpy arrays. - detection_events: shape (num_shots, num_detectors) - observable_flips: shape (num_shots, num_observables) """ - det_events, obs_flips = self.sampler.sample_batch(num_shots, seed) + if pauli_masks is None: + det_events, obs_flips = self.sampler.sample_batch(num_shots, seed) + else: + if self.pauli_frame_lookup is None: + msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" + raise ValueError(msg) + masks_arr = _pauli_masks_as_int64(pauli_masks) + det_events, obs_flips = self.sampler.sample_batch_with_pauli_masks( + num_shots, + self.pauli_frame_lookup, + masks_arr, + seed, + ) return np.array(det_events, dtype=bool), np.array(obs_flips, dtype=bool) @@ -2810,11 +4285,19 @@ def build_native_sampler( basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, sampling_model: Literal[ "dem", "influence_dem", "mnm", ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + *, + szz_runtime_barriers: bool | str = False, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> NativeSampler: """Build a PECOS native sampler for threshold estimation. @@ -2839,11 +4322,29 @@ def build_native_sampler( TickCircuit. ``"traced_qis"`` traces the lowered ideal Selene/QIS gate stream and replays that exact gate list into a TickCircuit before native PECOS fault analysis. + twirl: Optional Pauli-frame randomization layout. Canonical runtime + frame-output mode is normalized to the same abstract raw lookup. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. sampling_model: Which native sampling backend to use. ``"dem"`` - samples the generated decomposed DEM and is the default. + samples the generated source-graphlike DEM projection and is the + default; this is a decoder-facing approximation of raw hyperedges, + not the exact raw DEM. ``"influence_dem"`` uses the influence-map-based DemSampler with detector definitions. ``"mnm"`` is accepted for compatibility and maps to ``"influence_dem"``. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: NativeSampler that can generate samples for threshold estimation @@ -2856,8 +4357,14 @@ def build_native_sampler( >>> detection_events, observable_flips = sampler.sample(num_shots=10000) """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) + + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis + _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) + szz_physical_prefixes = _use_szz_physical_prefixes(noise, interaction_basis, circuit_source) topology = _cached_surface_native_topology( patch_key, num_rounds, @@ -2865,6 +4372,15 @@ def build_native_sampler( ancilla_budget, circuit_source, _noise_uses_dedicated_idle_noise(noise), + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + szz_physical_prefixes=szz_physical_prefixes, + resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -2874,13 +4390,38 @@ def build_native_sampler( ancilla_budget, circuit_source, noise.p1, + noise.p1_weights, noise.p2, + noise.p2_szz, + noise.p2_szzdg, noise.p_meas, noise.p_prep, decompose_errors=True, + p2_weights=noise.p2_weights, + p2_replacement_approximation=noise.p2_replacement_approximation, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, + p_idle_linear_rate=noise.p_idle_linear_rate, + p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + p_idle_x_linear_rate=noise.p_idle_x_linear_rate, + p_idle_y_linear_rate=noise.p_idle_y_linear_rate, + p_idle_z_linear_rate=noise.p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -2889,10 +4430,538 @@ def build_native_sampler( observables_json=topology.observables_json, num_detectors=topology.num_detectors, num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, + dem_string=dem_str, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) return _build_native_sampler_from_cached_surface_topology( topology, noise, sampling_model=sampling_model, ) + + +def build_native_sampler_from_dem( + decomposed_dem: str, + patch: SurfacePatch, + num_rounds: int, + basis: str = "Z", + *, + ancilla_budget: int | None = None, + circuit_source: Literal["abstract", "traced_qis"] = "abstract", + twirl: TwirlConfig | None = None, + interaction_basis: str | None = None, + check_plan: str | None = None, + clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, +) -> NativeSampler: + """Build a native sampler from a caller-supplied decomposed DEM string. + + The supplied DEM is parsed directly and stored verbatim on the returned + sampler. Surface topology metadata and optional Pauli-frame lookup are + taken from the same abstract/traced surface circuit family as + :func:`build_native_sampler`. + """ + ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) + + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis + basis = basis.upper() + patch_key = _surface_patch_cache_key(patch) + topology = _cached_surface_native_topology( + patch_key, + num_rounds, + basis, + ancilla_budget, + circuit_source, + include_idle_gates=False, + twirl=twirl, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + ) + sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() + return NativeSampler( + sampler=sampler, + detectors_json=topology.detectors_json, + observables_json=topology.observables_json, + num_detectors=topology.num_detectors, + num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, + sampling_model="dem", + dem_string=decomposed_dem, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, + ) + + +def decode_native_samples( + sampler: NativeSampler, + num_shots: int, + *, + dem: str | None = None, + decoder_type: str = "pymatching", + seed: int | None = None, + pauli_masks: Any | None = None, +) -> int: + """Sample, optionally apply a known Pauli-frame mask, de-mask, and decode.""" + from pecos_rslib.qec import SampleBatch + + dem_str = dem if dem is not None else sampler.dem_string + if dem_str is None: + msg = "decode_native_samples requires a DEM string; pass dem= or build the sampler with sampling_model='dem'" + raise ValueError(msg) + + masks_arr = _pauli_masks_as_int64(pauli_masks) if pauli_masks is not None else None + det_events, obs_flips = sampler.sample(num_shots, seed=seed, pauli_masks=masks_arr) + + if masks_arr is not None: + if sampler.pauli_frame_lookup is None: + msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" + raise ValueError(msg) + det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(masks_arr) + det_events = np.asarray(det_events, dtype=bool) ^ np.asarray(det_xor, dtype=bool) + obs_flips = np.asarray(obs_flips, dtype=bool) ^ np.asarray(obs_xor, dtype=bool) + + det_list = np.asarray(det_events, dtype=np.uint8).tolist() + obs_arr = np.asarray(obs_flips, dtype=np.uint64) + if obs_arr.ndim != 2: + msg = f"expected obs_flips to be 2-D, got shape {obs_arr.shape}" + raise ValueError(msg) + weights = (1 << np.arange(obs_arr.shape[1], dtype=np.uint64)).astype(np.uint64) + obs_masks = (obs_arr * weights).sum(axis=1).astype(np.uint64).tolist() + batch = SampleBatch(det_list, obs_masks) + return batch.decode_count(dem_str, decoder_type) + + +def demask_pauli_frame_records( + pauli_frame_lookup: Any, + raw_events: Any, + raw_obs: Any, + pauli_masks: Any, +) -> tuple[NDArray[np.bool_], NDArray[np.bool_]]: + """Cancel known Pauli-frame mask flips from detector/observable records.""" + events_arr = np.asarray(raw_events, dtype=bool) + obs_arr = np.asarray(raw_obs, dtype=bool) + masks_arr = _pauli_masks_as_int64(pauli_masks) + + if events_arr.ndim != 2: + msg = ( + f"raw_events must be 2-D of shape (num_shots, num_detectors); " + f"got ndim={events_arr.ndim}, shape={events_arr.shape}" + ) + raise ValueError(msg) + if obs_arr.ndim != 2: + msg = ( + f"raw_obs must be 2-D of shape (num_shots, num_observables); " + f"got ndim={obs_arr.ndim}, shape={obs_arr.shape}" + ) + raise ValueError(msg) + if masks_arr.ndim != 2: + msg = ( + f"pauli_masks must be 2-D of shape (num_shots, num_pauli_sites); " + f"got ndim={masks_arr.ndim}, shape={masks_arr.shape}" + ) + raise ValueError(msg) + if events_arr.shape[0] != obs_arr.shape[0] or events_arr.shape[0] != masks_arr.shape[0]: + msg = ( + "raw_events, raw_obs, and pauli_masks must have the same " + f"num_shots; got {events_arr.shape[0]}, {obs_arr.shape[0]}, " + f"{masks_arr.shape[0]}" + ) + raise ValueError(msg) + + expected_det = pauli_frame_lookup.num_detectors + expected_obs = pauli_frame_lookup.num_observables + expected_sites = pauli_frame_lookup.num_pauli_sites + if events_arr.shape[1] != expected_det: + msg = f"raw_events width {events_arr.shape[1]} != pauli_frame_lookup.num_detectors {expected_det}" + raise ValueError(msg) + if obs_arr.shape[1] != expected_obs: + msg = f"raw_obs width {obs_arr.shape[1]} != pauli_frame_lookup.num_observables {expected_obs}" + raise ValueError(msg) + if masks_arr.shape[1] != expected_sites: + msg = f"pauli_masks width {masks_arr.shape[1]} != pauli_frame_lookup.num_pauli_sites {expected_sites}" + raise ValueError(msg) + + det_xor, obs_xor = pauli_frame_lookup.compute_mask_xor(masks_arr) + return ( + events_arr ^ np.asarray(det_xor, dtype=bool), + obs_arr ^ np.asarray(obs_xor, dtype=bool), + ) + + +def _extract_pauli_masks_from_results( + results: dict[str, Any], + *, + num_rounds: int, + num_data: int, + num_shots: int, + patch: SurfacePatch | None = None, + basis: str = "Z", + twirl: TwirlConfig | None = None, +) -> NDArray[np.uint8]: + """Reconstruct per-shot Pauli-mask codes from Guppy result tags.""" + from pecos.qec.surface._twirl_sites import ( + mask_col_for, + mask_col_for_gate_operand, + num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_mask_gate_tag, + pauli_mask_round_tag, + site_idx_for_round, + ) + + site_schedule = "between_rounds" if twirl is None else twirl.site_schedule + if site_schedule == "before_two_qubit_gate": + if patch is None: + msg = "patch is required to extract before_two_qubit_gate Pauli masks" + raise ValueError(msg) + n_twirl = num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + out = np.zeros( + ( + num_shots, + num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ), + dtype=np.uint8, + ) + for site in range(n_twirl): + tag = pauli_mask_gate_tag(site) + if tag not in results: + msg = ( + f"missing Pauli-mask result tag {tag!r} (expected {n_twirl} gate " + f"tags for num_rounds={num_rounds}, basis={basis!r}); " + "did the program run with gate-local twirl enabled?" + ) + raise ValueError(msg) + per_gate = results[tag] + if len(per_gate) != num_shots: + msg = f"Pauli-mask tag {tag!r}: got {len(per_gate)} shots, expected {num_shots} shots" + raise ValueError(msg) + + bits = np.asarray(per_gate, dtype=np.uint8) + if bits.ndim != 2 or bits.shape[1] != 4: + msg = ( + f"Pauli-mask tag {tag!r} array has shape {bits.shape}, expected " + f"({num_shots}, 4) = (num_shots, 2*gate_operands)" + ) + raise ValueError(msg) + lo = bits[:, 0::2] + hi = bits[:, 1::2] + packed = (lo + (hi << 1)).astype(np.uint8) + for operand in range(2): + out[:, mask_col_for_gate_operand(site, operand)] = packed[:, operand] + active = _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + if np.any((~active) & (out != 0)): + msg = "malformed Pauli twirl bundle: inactive gate-local site recorded a non-identity Pauli" + raise ValueError(msg) + return out + + n_twirl = max(0, num_rounds - 1) + bits_per_round = 2 * num_data + out = np.zeros((num_shots, num_pauli_sites(num_rounds, num_data)), dtype=np.uint8) + + for r in range(n_twirl): + tag = pauli_mask_round_tag(r) + if tag not in results: + msg = ( + f"missing Pauli-mask result tag {tag!r} (expected {n_twirl} round " + f"tags for num_rounds={num_rounds}); did the program run with twirl enabled?" + ) + raise ValueError(msg) + per_round = results[tag] + if len(per_round) != num_shots: + msg = f"Pauli-mask tag {tag!r}: got {len(per_round)} shots, expected {num_shots} shots" + raise ValueError(msg) + + bits = np.asarray(per_round, dtype=np.uint8) + if bits.ndim != 2 or bits.shape[1] != bits_per_round: + msg = ( + f"Pauli-mask tag {tag!r} array has shape {bits.shape}, expected " + f"({num_shots}, {bits_per_round}) = (num_shots, 2*num_data)" + ) + raise ValueError(msg) + + lo = bits[:, 0::2] + hi = bits[:, 1::2] + packed = (lo + (hi << 1)).astype(np.uint8) + + site = site_idx_for_round(r) + for q in range(num_data): + out[:, mask_col_for(site, q, num_data)] = packed[:, q] + + active = _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + if np.any((~active) & (out != 0)): + msg = "malformed Pauli twirl bundle: inactive round site recorded a non-identity Pauli" + raise ValueError(msg) + return out + + +def _extract_pauli_activations_from_results( + results: dict[str, Any], + *, + num_rounds: int, + num_data: int, + num_shots: int, + patch: SurfacePatch | None = None, + basis: str = "Z", + twirl: TwirlConfig | None = None, +) -> NDArray[np.bool_]: + """Reconstruct per-shot twirl activation bits from Guppy result tags. + + Legacy `twirl_probability=1.0` bundles have no activation tags; they are + interpreted as active at every site. Scaled-twirl bundles must carry explicit + activation tags so skipped sites and active identity draws remain auditable. + """ + from pecos.qec.surface._twirl_sites import ( + mask_col_for, + mask_col_for_gate_operand, + num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_active_gate_tag, + pauli_active_round_tag, + site_idx_for_round, + ) + + site_schedule = "between_rounds" if twirl is None else twirl.site_schedule + probability = 1.0 if twirl is None else float(twirl.twirl_probability) + has_active_tags = any(str(name).startswith("pauli_active:") for name in results) + require_active_tags = has_active_tags or probability != 1.0 + + if site_schedule == "before_two_qubit_gate": + if patch is None: + msg = "patch is required to extract before_two_qubit_gate Pauli activations" + raise ValueError(msg) + n_twirl = num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + out = np.ones( + ( + num_shots, + num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ), + dtype=bool, + ) + if not require_active_tags: + return out + out[...] = False + for site in range(n_twirl): + tag = pauli_active_gate_tag(site) + if tag not in results: + msg = f"missing Pauli-activation result tag {tag!r}" + raise ValueError(msg) + per_gate = results[tag] + if len(per_gate) != num_shots: + msg = f"Pauli-activation tag {tag!r}: got {len(per_gate)} shots, expected {num_shots} shots" + raise ValueError(msg) + bits = np.asarray(per_gate, dtype=bool) + if bits.ndim != 2 or bits.shape[1] != 2: + msg = ( + f"Pauli-activation tag {tag!r} array has shape {bits.shape}, " + f"expected ({num_shots}, 2) = (num_shots, gate_operands)" + ) + raise ValueError(msg) + for operand in range(2): + out[:, mask_col_for_gate_operand(site, operand)] = bits[:, operand] + return out + + n_twirl = max(0, num_rounds - 1) + out = np.ones((num_shots, num_pauli_sites(num_rounds, num_data)), dtype=bool) + if not require_active_tags: + return out + out[...] = False + for r in range(n_twirl): + tag = pauli_active_round_tag(r) + if tag not in results: + msg = f"missing Pauli-activation result tag {tag!r}" + raise ValueError(msg) + per_round = results[tag] + if len(per_round) != num_shots: + msg = f"Pauli-activation tag {tag!r}: got {len(per_round)} shots, expected {num_shots} shots" + raise ValueError(msg) + bits = np.asarray(per_round, dtype=bool) + if bits.ndim != 2 or bits.shape[1] != num_data: + msg = ( + f"Pauli-activation tag {tag!r} array has shape {bits.shape}, " + f"expected ({num_shots}, {num_data}) = (num_shots, num_data)" + ) + raise ValueError(msg) + site = site_idx_for_round(r) + for q in range(num_data): + out[:, mask_col_for(site, q, num_data)] = bits[:, q] + return out + + +def _sample_pauli_sideband_results_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> dict[str, list[list[Any]]]: + """Run the Guppy memory program with twirling and harvest side-band tags.""" + from selene_sim import SimpleRuntime, Stim, build + + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + + if twirl is None or rng is None: + msg = "sample_pauli_masks_from_guppy requires both twirl and rng to be set" + raise ValueError(msg) + twirl.validate_runtime_supported() + if num_rounds < 1: + msg = f"num_rounds must be >= 1, got {num_rounds}" + raise ValueError(msg) + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) + + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_mask_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + num_qubits = get_num_qubits( + patch=patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + ) + + results: dict[str, list[list[Any]]] = {} + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=num_qubits, + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + for name, values in shot_results: + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + if name.startswith(("pauli_mask:", "pauli_active:")): + results.setdefault(name, []).append(shot_value) + return results + + +def sample_pauli_masks_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> NDArray[np.uint8]: + """Run the Guppy memory program with twirling and harvest mask columns.""" + twirl.validate_runtime_supported() + num_data = patch.geometry.num_data + results = _sample_pauli_sideband_results_from_guppy( + patch, + num_rounds=num_rounds, + num_shots=num_shots, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) + + return _extract_pauli_masks_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + + +def sample_pauli_activations_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> NDArray[np.bool_]: + """Run the Guppy memory program with twirling and harvest activation bits.""" + twirl.validate_runtime_supported() + num_data = patch.geometry.num_data + results = _sample_pauli_sideband_results_from_guppy( + patch, + num_rounds=num_rounds, + num_shots=num_shots, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) + + return _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py index c5d10a619..4c5f94c1f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py +++ b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py @@ -527,7 +527,7 @@ def stab_coords(self) -> list[dict[str, list[tuple[float, float]]]]: with keys "X" and "Z" mapping to ancilla (x, y) positions. These coordinates match the detector annotations in the Stim circuit. - Used as input to ``ObservableSubgraphDecoder``. + Used as input to ``LogicalSubgraphDecoder``. """ result = [] for ps in self._patches.values(): @@ -609,10 +609,10 @@ def build_sampler_and_decoder( """Build a DemSampler and OSD decoder without any string round-trip. Returns: - Tuple of (DemSampler, ObservableSubgraphDecoder, dem_str). + Tuple of (DemSampler, LogicalSubgraphDecoder, dem_str). dem_str is also returned for compatibility with existing code. """ - from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, ObservableSubgraphDecoder + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, LogicalSubgraphDecoder tc = self.to_tick_circuit() dc = tc.to_dag_circuit() @@ -642,7 +642,7 @@ def build_sampler_and_decoder( dem_str = str(dem) sc = self.stab_coords() - decoder = ObservableSubgraphDecoder(dem_str, sc, inner_decoder) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner_decoder) return sampler, decoder, dem_str @@ -836,6 +836,14 @@ def build_algorithm_descriptor( seg_dems.append("\n".join(lines)) + # Physical code distance for latency/windowing decisions. With multiple + # patches use the minimum (the weakest bound governs latency). This is the + # real surface-code distance, NOT a count of logical patches. + distance = min( + (min(ps.patch.geometry.dx, ps.patch.geometry.dz) for ps in self._patches.values()), + default=0, + ) + return { "segments": [ { @@ -848,6 +856,7 @@ def build_algorithm_descriptor( "boundary_gates": boundary_gates, "num_observables": num_patches * 2, "full_dem": full_dem, + "distance": distance, } def build_decoder( @@ -860,7 +869,7 @@ def build_decoder( inner_decoder: str = "fusion_blossom_serial", use_stim_dem: bool = True, ) -> tuple[object, object]: - """Build an ObservableSubgraphDecoder for this circuit. + """Build an LogicalSubgraphDecoder for this circuit. Args: p1: Single-qubit depolarizing error rate. @@ -872,10 +881,10 @@ def build_decoder( mechanisms). If False, use PECOS-native DEM pipeline. Returns: - Tuple of (stim.Circuit, ObservableSubgraphDecoder). + Tuple of (stim.Circuit, LogicalSubgraphDecoder). """ import stim - from pecos_rslib.qec import ObservableSubgraphDecoder + from pecos_rslib.qec import LogicalSubgraphDecoder stim_str = self.to_stim(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) circuit = stim.Circuit(stim_str) @@ -887,7 +896,7 @@ def build_decoder( dem_str = self.build_dem(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) sc = self.stab_coords() - decoder = ObservableSubgraphDecoder(dem_str, sc, inner_decoder) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner_decoder) return circuit, decoder diff --git a/python/quantum-pecos/src/pecos/quantum/__init__.py b/python/quantum-pecos/src/pecos/quantum/__init__.py index d9c190b84..6cd99b993 100644 --- a/python/quantum-pecos/src/pecos/quantum/__init__.py +++ b/python/quantum-pecos/src/pecos/quantum/__init__.py @@ -69,6 +69,13 @@ from typing import TYPE_CHECKING from pecos.quantum import commute, gate_groups +from pecos.quantum.hosted import ( + HOST_ID_META_KEY, + LOCAL_ROLE_META_KEY, + HostedGateRecord, + HostedOperationBinding, + validate_hosted_operations, +) from pecos.typing import INTEGER_TYPES if TYPE_CHECKING: @@ -92,6 +99,7 @@ H5, H6, ISWAP, + PHYSICAL_DURATION_META_KEY, SWAP, SX, SXX, @@ -268,7 +276,10 @@ def pauli_string( "H4", "H5", "H6", + "HOST_ID_META_KEY", "ISWAP", + "LOCAL_ROLE_META_KEY", + "PHYSICAL_DURATION_META_KEY", "SWAP", "SX", "SXX", @@ -291,6 +302,8 @@ def pauli_string( "GateRegistry", "GateType", "H", + "HostedGateRecord", + "HostedOperationBinding", "HugrConversionError", "Pauli", "PauliSequence", @@ -323,4 +336,5 @@ def pauli_string( "is_quantum_operation", "pauli_string", "sparse_stab", + "validate_hosted_operations", ] diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py new file mode 100644 index 000000000..df642087a --- /dev/null +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -0,0 +1,324 @@ +"""Hosted-operation metadata utilities. + +A hosted operation is a source-local operation whose semantic role is tied to a +later host operation. For example, an SZZ surface-code data-prefix pulse can +declare ``host_id=`` and ``local_role=basis_prefix`` while the +lowered SZZ/SZZdg host carries the same ``host_id``. Runtimes are free to +lower and schedule the gates, but traced circuits can then fail loudly if the +source-host relationship was lost or separated too far. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + +HOST_ID_META_KEY = "host_id" +LOCAL_ROLE_META_KEY = "local_role" +HOSTED_PROVENANCE_META_KEYS = ( + "source_kind", + "source_label", + "source_gate", + "szz_host_label", + "source_lowering_required", + "label", +) + + +@dataclass(frozen=True) +class HostedGateRecord: + """A traced gate carrying hosted-operation metadata.""" + + tick_index: int + gate_index: int + gate_name: str + qubits: tuple[int, ...] + host_id: str + local_role: str + metadata: Mapping[str, object] + + +@dataclass(frozen=True) +class HostedOperationBinding: + """A concrete local-to-host relationship recovered from traced metadata.""" + + host_id: str + local_role: str + local: HostedGateRecord + host: HostedGateRecord + tick_separation: int + + +def validate_hosted_operations( + tick_circuit: object, + *, + host_id_key: str = HOST_ID_META_KEY, + local_role_key: str = LOCAL_ROLE_META_KEY, + max_tick_separation: int | None = None, + require_shared_qubit: bool = True, + require_host_after_local: bool = True, + require_unique_host_id: bool = False, + context: str = "hosted operation validation", +) -> tuple[HostedOperationBinding, ...]: + """Validate and bind hosted-operation metadata in a traced ``TickCircuit``. + + A gate with ``local_role_key`` is a hosted local operation and must carry a + non-empty ``host_id_key``. By default it binds to the first later gate with + the same host id and no local role. When ``require_shared_qubit`` is true, + the bound host must touch at least one of the local gate's qubits. + + Args: + tick_circuit: PECOS ``TickCircuit``-like object with ``num_ticks()``, + ``get_tick()``, tick ``gate_batches()``, and optional + ``get_gate_meta(tick, gate, key)`` support. + host_id_key: Metadata key naming the source host relationship. + local_role_key: Metadata key identifying local operations. + max_tick_separation: Optional maximum absolute signed tick separation + between the local and selected host operation. + require_shared_qubit: Require the selected host to share a qubit with + the local operation. + require_host_after_local: Require host tick/gate order to follow local + tick/gate order. Disable only for metadata-shape audits that need + to report ordering drift instead of rejecting it immediately. + require_unique_host_id: Require each host id to appear on at most one + host gate. Enable this for strict validation because repeated host + records make first-later-host binding ambiguous across repeated + helper invocations. + context: Human-readable context included in failures. + + Returns: + Tuple of recovered local-to-host bindings. + + Raises: + ValueError: If a local operation is missing a host id, has no later + compatible host, or exceeds ``max_tick_separation``. + TypeError: If the supplied object is not TickCircuit-like. + """ + if max_tick_separation is not None and max_tick_separation < 0: + msg = f"{context}: max_tick_separation must be non-negative, got {max_tick_separation}" + raise ValueError(msg) + + records = _hosted_gate_records( + tick_circuit, + host_id_key=host_id_key, + local_role_key=local_role_key, + context=context, + ) + if require_unique_host_id: + _raise_if_repeated_host_records( + records, + host_id_key=host_id_key, + context=context, + ) + bindings: list[HostedOperationBinding] = [] + for local in records: + if not local.local_role: + continue + if not local.host_id: + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} has local_role " + f"{local.local_role!r} but no non-empty {host_id_key!r} metadata." + ) + raise ValueError(msg) + host_candidates = _matching_host_records( + records, + local, + require_shared_qubit=require_shared_qubit, + ) + if not host_candidates: + shared_clause = " sharing a qubit" if require_shared_qubit else "" + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} and local_role {local.local_role!r} has no " + f"host gate{shared_clause} carrying the same host_id." + ) + raise ValueError(msg) + later_hosts = [candidate for candidate in host_candidates if _gate_order(candidate) > _gate_order(local)] + if require_host_after_local and not later_hosts: + nearest_host = host_candidates[-1] + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} and local_role {local.local_role!r} has matching " + f"host metadata only before it; nearest host is {nearest_host.gate_name}" + f"@t{nearest_host.tick_index}/g{nearest_host.gate_index}. This " + "indicates source-host ordering drift in the traced runtime schedule." + ) + raise ValueError(msg) + host = later_hosts[0] if later_hosts else host_candidates[-1] + tick_separation = host.tick_index - local.tick_index + if max_tick_separation is not None and abs(tick_separation) > max_tick_separation: + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} binds to {host.gate_name}@t{host.tick_index}/" + f"g{host.gate_index} with signed tick separation {tick_separation}, " + f"exceeding max_tick_separation={max_tick_separation}." + ) + raise ValueError(msg) + bindings.append( + HostedOperationBinding( + host_id=local.host_id, + local_role=local.local_role, + local=local, + host=host, + tick_separation=tick_separation, + ), + ) + return tuple(bindings) + + +def _matching_host_records( + records: Sequence[HostedGateRecord], + local: HostedGateRecord, + *, + require_shared_qubit: bool, +) -> tuple[HostedGateRecord, ...]: + local_qubits = set(local.qubits) + candidates: list[HostedGateRecord] = [] + for candidate in records: + if candidate.host_id != local.host_id or candidate.local_role: + continue + if require_shared_qubit and local_qubits.isdisjoint(candidate.qubits): + continue + candidates.append(candidate) + return tuple(candidates) + + +def _raise_if_repeated_host_records( + records: Sequence[HostedGateRecord], + *, + host_id_key: str, + context: str, +) -> None: + host_records_by_id: dict[str, list[HostedGateRecord]] = {} + for record in records: + if record.local_role or not record.host_id: + continue + host_records_by_id.setdefault(record.host_id, []).append(record) + repeated = {host_id: host_records for host_id, host_records in host_records_by_id.items() if len(host_records) > 1} + if not repeated: + return + host_id, host_records = next(iter(repeated.items())) + first_locations = ", ".join( + f"{record.gate_name}@t{record.tick_index}/g{record.gate_index}" for record in host_records[:4] + ) + extra_count = len(host_records) - 4 + if extra_count > 0: + first_locations = f"{first_locations}, ... (+{extra_count} more)" + msg = ( + f"{context}: hosted metadata key {host_id_key!r} is ambiguous because " + f"host_id {host_id!r} appears on {len(host_records)} host gates " + f"({first_locations}). Strict hosted-operation validation requires " + "host ids to identify one source host gate; include invocation-scoped " + "metadata before validating ordering or tick separation." + ) + raise ValueError(msg) + + +def _gate_order(record: HostedGateRecord) -> tuple[int, int]: + return (record.tick_index, record.gate_index) + + +def _hosted_gate_records( + tick_circuit: object, + *, + host_id_key: str, + local_role_key: str, + context: str, +) -> tuple[HostedGateRecord, ...]: + records: list[HostedGateRecord] = [] + for tick_index, gate_index, gate in _iter_tick_gates(tick_circuit, context=context): + metadata = _gate_metadata( + tick_circuit, + tick_index, + gate_index, + keys=(host_id_key, local_role_key, *HOSTED_PROVENANCE_META_KEYS), + ) + host_id = _metadata_text(metadata, host_id_key) + local_role = _metadata_text(metadata, local_role_key) + if not host_id and not local_role: + continue + records.append( + HostedGateRecord( + tick_index=tick_index, + gate_index=gate_index, + gate_name=_gate_name(gate), + qubits=tuple(int(qubit) for qubit in getattr(gate, "qubits", ())), + host_id=host_id, + local_role=local_role, + metadata=metadata, + ), + ) + return tuple(records) + + +def _iter_tick_gates( + tick_circuit: object, + *, + context: str, +) -> tuple[tuple[int, int, object], ...]: + try: + num_ticks = int(tick_circuit.num_ticks()) + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with num_ticks()." + raise TypeError(msg) from exc + + gate_locations: list[tuple[int, int, object]] = [] + for tick_index in range(num_ticks): + try: + tick = tick_circuit.get_tick(tick_index) + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with get_tick()." + raise TypeError(msg) from exc + if tick is None: + continue + try: + gate_batches = tick.gate_batches() + except AttributeError as exc: + msg = f"{context}: expected TickCircuit ticks with gate_batches()." + raise TypeError(msg) from exc + gate_locations.extend((tick_index, gate_index, gate) for gate_index, gate in enumerate(gate_batches)) + return tuple(gate_locations) + + +def _gate_metadata( + tick_circuit: object, + tick_index: int, + gate_index: int, + *, + keys: Sequence[str], +) -> dict[str, object]: + getter = getattr(tick_circuit, "get_gate_meta", None) + if getter is None: + return {} + metadata: dict[str, object] = {} + for key in keys: + try: + value = getter(tick_index, gate_index, key) + except (AttributeError, IndexError, KeyError, TypeError): + continue + if value is not None: + metadata[key] = value + return metadata + + +def _metadata_text(metadata: Mapping[str, object], key: str) -> str: + value = metadata.get(key) + return "" if value is None else str(value) + + +def _gate_name(gate: object) -> str: + gate_type = getattr(gate, "gate_type", None) + name = getattr(gate_type, "name", None) + if name is not None: + return str(name) + if gate_type is not None: + return str(gate_type) + return type(gate).__name__ diff --git a/python/quantum-pecos/src/pecos/simulators/mps_pytket/__init__.py b/python/quantum-pecos/src/pecos/simulators/mps_pytket/__init__.py index b96f5c02d..370d53bc5 100644 --- a/python/quantum-pecos/src/pecos/simulators/mps_pytket/__init__.py +++ b/python/quantum-pecos/src/pecos/simulators/mps_pytket/__init__.py @@ -15,4 +15,7 @@ # specific language governing permissions and limitations under the License. from pecos.simulators.mps_pytket import bindings +from pecos.simulators.mps_pytket._nvmath_compat import patch_nvmath_cupy_external_stream from pecos.simulators.mps_pytket.state import MPS + +patch_nvmath_cupy_external_stream() diff --git a/python/quantum-pecos/src/pecos/simulators/mps_pytket/_nvmath_compat.py b/python/quantum-pecos/src/pecos/simulators/mps_pytket/_nvmath_compat.py new file mode 100644 index 000000000..9efe60971 --- /dev/null +++ b/python/quantum-pecos/src/pecos/simulators/mps_pytket/_nvmath_compat.py @@ -0,0 +1,62 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Compatibility patches for pytket-cutensornet's nvmath dependency.""" + +from __future__ import annotations + +from importlib import import_module + +_PATCHED_ATTR = "_pecos_cupy_stream_from_external_patch" + + +class _CudaStreamHolder: + """Adapter object implementing the CUDA stream protocol for a raw stream handle.""" + + def __init__(self, handle: int) -> None: + self.handle = int(handle) + + def __cuda_stream__(self) -> tuple[int, int]: + return (0, self.handle) + + +def patch_nvmath_cupy_external_stream() -> bool: + """Use CuPy's supported external-stream API in nvmath when it is available. + + nvmath-python 0.9.0 still wraps raw CUDA stream pointers with + ``cupy.cuda.ExternalStream``, which is deprecated in CuPy 14. CuPy's + replacement API accepts an object implementing the CUDA stream protocol. + """ + try: + cp = import_module("cupy") + package_ifc_cupy = import_module("nvmath.internal.package_ifc_cupy") + except ImportError: + return False + + try: + cupy_package = package_ifc_cupy.CupyPackage + from_external = getattr(cp.cuda.Stream, "from_external", None) + except AttributeError: + return False + + if getattr(cupy_package, _PATCHED_ATTR, False): + return True + + if from_external is None: + return False + + def create_external_stream(device_id: int, stream_ptr: int) -> object: + del device_id + return from_external(_CudaStreamHolder(stream_ptr)) + + cupy_package.create_external_stream = staticmethod(create_external_stream) + setattr(cupy_package, _PATCHED_ATTR, True) + return True diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/readme.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/readme.rs index f0bb90df0..0a457e3a4 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/readme.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/readme.rs @@ -18,7 +18,7 @@ fn test_readme_rust_1() -> Result<(), Box> { "#); // Run 10 shots - let results = sim(circuit).seed(42).run(10)?; + let results = sim(circuit).seed(42).shots(10).run()?; println!("{:?}", results); // 0 = both |0⟩, 3 = both |1⟩ (always correlated!) Ok(()) diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs index bd606961e..b143bfa86 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs @@ -33,10 +33,10 @@ let qasm_code = r#" let program = Qasm::from_string(qasm_code); // Simple simulation -let results = sim(program.clone()).run(1000)?; +let results = sim(program.clone()).shots(1000).run()?; // With configuration -let results = sim(program).seed(42).run(1000)?; +let results = sim(program).seed(42).shots(1000).run()?; Ok(()) } @@ -71,7 +71,7 @@ let qasm_code = r#" let program = Qasm::from_string(qasm_code); // Simple simulation with builder pattern -let results = sim(program.clone()).run(1000)?; +let results = sim(program.clone()).shots(1000).run()?; // With more configuration options let results = sim(program) @@ -79,7 +79,7 @@ let results = sim(program) .noise(DepolarizingNoiseModel::builder().with_uniform_probability(0.01)) .workers(4) // Explicitly set number of threads // .auto_workers() // Or use all available CPU cores - .run(1000)?; + .shots(1000).run()?; Ok(()) } @@ -191,7 +191,7 @@ let noise = GeneralNoiseModelBuilder::new() .with_seed(42); // Deterministic noise // Use with sim() -let results = sim(program).noise(noise).run(1000)?; +let results = sim(program).noise(noise).shots(1000).run()?; Ok(()) } @@ -228,12 +228,12 @@ let program = Qasm::from_string(qasm_code); // Sparse stabilizer (default, efficient for Clifford circuits) let results = sim(program.clone()) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; // State vector (for non-Clifford circuits) let results = sim(program) .quantum(state_vector()) - .run(1000)?; + .shots(1000).run()?; Ok(()) } @@ -266,7 +266,7 @@ let qasm_code = r#" "#; let program = Qasm::from_string(qasm_code); -let results = sim(program).run(1000)?; +let results = sim(program).shots(1000).run()?; // Results come as ShotVec println!("Got {} shots", results.len()); @@ -371,7 +371,7 @@ fn ghz_noise_example() -> Result<(), PecosError> { .with_seed(12345); // Deterministic noise // Run simulation - let results = sim(program).noise(noise).seed(42).run(1000)?; + let results = sim(program).noise(noise).seed(42).shots(1000).run()?; println!("GHZ state results: {:?}", results); Ok(()) @@ -410,13 +410,13 @@ let qasm_code = r#" let program = Qasm::from_string(qasm_code); // Single threaded (default) -let results = sim(program.clone()).workers(1).run(1000)?; +let results = sim(program.clone()).workers(1).shots(1000).run()?; // Explicit thread count -let results = sim(program.clone()).workers(4).run(1000)?; +let results = sim(program.clone()).workers(4).shots(1000).run()?; // Automatically use all available cores -let results = sim(program).auto_workers().run(1000)?; +let results = sim(program).auto_workers().shots(1000).run()?; Ok(()) } diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_simulators.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_simulators.rs index 14d81fe98..98eed26cf 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_simulators.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_simulators.rs @@ -17,12 +17,12 @@ fn test_user_guide_simulators_rust_2() -> Result<(), Box> "#; let program = Qasm::from_string(qasm_code); // SparseStab is used by default -let results = sim(program.clone()).run(1000)?; +let results = sim(program.clone()).shots(1000).run()?; // Or explicitly select it let results = sim(program) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; Ok(()) } @@ -43,7 +43,7 @@ fn test_user_guide_simulators_rust_3() -> Result<(), Box> let program = Qasm::from_string(qasm_code); let results = sim(program) .quantum(state_vector()) - .run(100)?; + .shots(100).run()?; Ok(()) } @@ -129,16 +129,16 @@ fn test_user_guide_simulators_rust_6() -> Result<(), Box> "#); // Default (sparse stabilizer for Clifford circuits) -let results = sim(circuit.clone()).run(1000)?; +let results = sim(circuit.clone()).shots(1000).run()?; // Explicit simulator selection let results = sim(circuit.clone()) .quantum(state_vector()) - .run(1000)?; + .shots(1000).run()?; let results = sim(circuit) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; Ok(()) } diff --git a/python/quantum-pecos/tests/guppy/test_all_gates.py b/python/quantum-pecos/tests/guppy/test_all_gates.py index 55c7196ba..2c9fd1a79 100644 --- a/python/quantum-pecos/tests/guppy/test_all_gates.py +++ b/python/quantum-pecos/tests/guppy/test_all_gates.py @@ -43,7 +43,7 @@ def run_circuit( def get_measurements(results: dict) -> list: """Extract measurements from results.""" - return results.get("measurements", []) + return results["measurements"] class TestSingleQubitGates: diff --git a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py index 969ecf562..048f24791 100644 --- a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py +++ b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py @@ -2,7 +2,7 @@ import pytest from guppylang import guppy -from guppylang.std.quantum import h, measure, qubit +from guppylang.std.quantum import h, measure, qubit, x from pecos import Guppy, sim from pecos_rslib import state_vector @@ -24,7 +24,7 @@ def quantum_add() -> bool: results = sim(Guppy(quantum_add)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For single bool return, measurements is [[1], [0], ...] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 @@ -47,7 +47,7 @@ def quantum_bool_logic() -> bool: results = sim(Guppy(quantum_bool_logic)).qubits(2).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 @@ -68,7 +68,7 @@ def quantum_compare() -> bool: results = sim(Guppy(quantum_compare)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 assert 0 in measurements @@ -93,7 +93,7 @@ def quantum_loop() -> bool: results = sim(Guppy(quantum_loop)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 assert 0 in measurements @@ -117,7 +117,7 @@ def quantum_chain() -> bool: results = sim(Guppy(quantum_chain)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 assert 0 in measurements @@ -149,7 +149,113 @@ def quantum_measure_math() -> bool: results = sim(Guppy(quantum_measure_math)).qubits(3).quantum(state_vector()).seed(42).run(20).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 20 # Should have mix unless both m1 and m2 are 0 (25% chance) + + +def test_euclidean_division_semantics() -> None: + """Negative-operand division follows the HUGR spec (Euclidean). + + idivmod_s is defined as q*m+r=n with 0<=r bool: + q = qubit() + a = -3 + if a % 2 == 1: + x(q) + return measure(q) + + @guppy + def euclid_div() -> bool: + q = qubit() + a = -3 + if a // 2 == -2: + x(q) + return measure(q) + + for prog in (euclid_mod, euclid_div): + results = sim(Guppy(prog)).qubits(1).quantum(state_vector()).seed(1).run(3).to_dict() + raw_measurements = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert measurements == [1, 1, 1], f"Euclidean semantics violated: {measurements}" + + +def test_shift_semantics() -> None: + """Left/right shifts on positive values, X-anchored.""" + + @guppy + def shifts() -> bool: + q = qubit() + a = 1 + b = 16 + if (a << 3) == 8 and (b >> 2) == 4: + x(q) + return measure(q) + + results = sim(Guppy(shifts)).qubits(1).quantum(state_vector()).seed(1).run(3).to_dict() + raw_measurements = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert measurements == [1, 1, 1], f"shift semantics violated: {measurements}" + + +def test_zero_iteration_loop() -> None: + """A range(0) loop must run zero iterations and fall through cleanly.""" + + @guppy + def zero_iters() -> bool: + q = qubit() + count = 0 + for _i in range(0): + count = count + 1 + if count == 0: + x(q) + return measure(q) + + results = sim(Guppy(zero_iters)).qubits(1).quantum(state_vector()).seed(1).run(3).to_dict() + raw_measurements = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert measurements == [1, 1, 1], f"zero-iteration loop misbehaved: {measurements}" + + +def test_division_by_zero_panics() -> None: + """Division by zero is a runtime error per the HUGR spec (m=0 panics).""" + + @guppy + def div_zero() -> bool: + q = qubit() + a = 5 + b = 0 + if a // b == 0: + x(q) + return measure(q) + + with pytest.raises(RuntimeError, match="division by zero"): + sim(Guppy(div_zero)).qubits(1).quantum(state_vector()).seed(1).run(1).to_dict() + + +def test_recursion_rejected_loudly() -> None: + """Recursive guppy functions must produce a clear engine error, not a + hang or silent truncation.""" + + @guppy + def recurse(n: int) -> int: + if n <= 0: + return 0 + return recurse(n - 1) + + @guppy + def recursive_main() -> bool: + q = qubit() + if recurse(3) == 0: + x(q) + return measure(q) + + with pytest.raises(RuntimeError, match="recursion is not supported"): + sim(Guppy(recursive_main)).qubits(1).quantum(state_vector()).seed(1).run(1).to_dict() diff --git a/python/quantum-pecos/tests/guppy/test_check_hugr_format.py b/python/quantum-pecos/tests/guppy/test_check_hugr_format.py index d4f18b6e4..5098544db 100644 --- a/python/quantum-pecos/tests/guppy/test_check_hugr_format.py +++ b/python/quantum-pecos/tests/guppy/test_check_hugr_format.py @@ -1,7 +1,5 @@ """Check HUGR format from guppylang.""" -import json - import pytest @@ -19,36 +17,14 @@ def simple() -> bool: h(q) return measure(q) - # Compile to HUGR + # Compile to HUGR (a hugr.package.Package) hugr = simple.compile() - # Check binary format - hugr.to_bytes() - - # Check JSON/string format - # Note: to_str() returns HUGR envelope format with header, while to_json() returns pure JSON - if hasattr(hugr, "to_str"): - hugr_str = hugr.to_str() - # Check if it's the envelope format with header - if hugr_str.startswith("HUGRiHJv"): - # Skip header (8 bytes), format byte (1 byte), and extra byte (1 byte) - json_start = hugr_str.find("{", 9) # Find the start of JSON after header - if json_start != -1: - hugr_str = hugr_str[json_start:] - else: - msg = "Could not find JSON start in HUGR envelope" - raise ValueError(msg) - else: - hugr_str = hugr.to_json() - - hugr_dict = json.loads(hugr_str) - - # Check if it's a single HUGR or a Package - if "modules" in hugr_dict or "nodes" in hugr_dict: - pass - - # Save JSON for inspection - import tempfile + # Binary Model envelope should be non-empty bytes + hugr_bytes = hugr.to_bytes() + assert isinstance(hugr_bytes, bytes), "to_bytes() should return bytes" + assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(hugr_dict, f, indent=2) + # Inspect the Package structure directly + assert len(hugr.modules) >= 1, "HUGR should contain at least one module" + assert sum(1 for _ in hugr.modules[0].nodes()) > 0, "First module should contain at least one node" diff --git a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py index 82d9826db..d63297212 100644 --- a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py @@ -47,76 +47,35 @@ def test_function_on_both_pipelines( result_obj = builder.run(shots) result_dict = result_obj.to_dict() - # Format results to match expected structure - measurements = [] - if "measurements" in result_dict: - # measurements is a list of lists like [[1], [0, 1], ...] - # For functions returning single bool, extract the last measurement per shot - raw_measurements = result_dict["measurements"] - if raw_measurements and isinstance(raw_measurements[0], list): - # Check if function returns single bool or tuple - import inspect - - actual_func = func - if hasattr(func, "wrapped") and hasattr( - func.wrapped, - "python_func", - ): - actual_func = func.wrapped.python_func - try: - sig = inspect.signature(actual_func) - return_type = sig.return_annotation - is_tuple_return = hasattr(return_type, "__origin__") and return_type.__origin__ is tuple - except (ValueError, TypeError): - is_tuple_return = False - - if is_tuple_return: - # Return full measurement tuples - measurements = [tuple(m) for m in raw_measurements] - else: - # For single bool return, take the last measurement from each shot - measurements = [m[-1] if m else 0 for m in raw_measurements] - else: - measurements = raw_measurements - elif "measurement_0" in result_dict: - # Handle multiple measurements - num_shots = len(result_dict["measurement_0"]) - measurement_keys = sorted( - [k for k in result_dict if k.startswith("measurement_")], - ) - num_measurements = len(measurement_keys) - - for i in range(num_shots): - result_tuple = [bool(result_dict[key][i]) for key in measurement_keys] - - # Check function signature to determine if it returns a tuple - # For now, if there's more than one measurement but function returns single bool, - # take the last measurement as the return value - import inspect - - # For Guppy functions, we need to check the wrapped function - actual_func = func - if hasattr(func, "wrapped") and hasattr( - func.wrapped, - "python_func", - ): - actual_func = func.wrapped.python_func - + # Format results to match expected structure. + # "measurements" holds one row per shot like [[1], [0, 1], ...]; + # a missing key is a hard failure (reported via the except below). + raw_measurements = result_dict["measurements"] + if raw_measurements and isinstance(raw_measurements[0], list): + # Check if function returns single bool or tuple + import inspect + + actual_func = func + if hasattr(func, "wrapped") and hasattr( + func.wrapped, + "python_func", + ): + actual_func = func.wrapped.python_func + try: sig = inspect.signature(actual_func) return_type = sig.return_annotation - - # Check if return type is a tuple is_tuple_return = hasattr(return_type, "__origin__") and return_type.__origin__ is tuple - if is_tuple_return or num_measurements == 1: - # For tuple returns or single measurement, use all measurements - measurements.append( - (tuple(result_tuple) if len(result_tuple) > 1 else result_tuple[0]), - ) - else: - # For single bool return with multiple measurements, take the last one - measurements.append(result_tuple[-1]) - elif "result" in result_dict: - measurements = result_dict["result"] + except (ValueError, TypeError): + is_tuple_return = False + + if is_tuple_return: + # Return full measurement tuples + measurements = [tuple(m) for m in raw_measurements] + else: + # For single bool return, take the last measurement from each shot + measurements = [m[-1] if m else 0 for m in raw_measurements] + else: + measurements = raw_measurements func_name = getattr( func, @@ -134,11 +93,11 @@ def test_function_on_both_pipelines( "error": None, } except Exception as e: - results["hugr_llvm"] = { - "success": False, - "result": None, - "error": str(e), - } + # A pipeline failure must FAIL the test: every semantic + # assertion in this file is gated behind success, so converting + # exceptions into success=False used to make any engine + # regression pass everything. + pytest.fail(f"guppy pipeline failed for {func}: {e}") return results @@ -181,14 +140,12 @@ def hadamard_test() -> bool: hadamard_test, shots=50, ) - assert results.get("hugr_llvm", {}).get( - "success", - False, - ), f"HUGR-LLVM failed: {results.get('hugr_llvm', {}).get('error')}" - # PHIR might not be available on all systems - if "phir" in results: - # print(f"PHIR result: {results['phir']}") - pass + rows = results["hugr_llvm"]["result"]["results"] + assert len(rows) == 50 + # H|0> is an exact 50/50 superposition: both outcomes must appear + # with a near-even split. + ones = sum(int(r) for r in rows) + assert 12 <= ones <= 38, f"H distribution off: {ones}/50 ones" def test_pauli_gates(self, pipeline_tester: GuppyPipelineTest) -> None: """Test all Pauli gates (X, Y, Z).""" @@ -268,7 +225,9 @@ def bell_state() -> tuple[bool, bool]: decoded_measurements = decode_integer_results(measurements, 2) correlated = sum(1 for (a, b) in decoded_measurements if a == b) correlation_rate = correlated / len(decoded_measurements) - assert correlation_rate > 0.8, f"Bell state should be highly correlated, got {correlation_rate:.2%}" + assert ( + correlation_rate == 1.0 + ), f"Bell correlation is EXACT on a noiseless statevector, got {correlation_rate:.2%}" # Verify PHIR pipeline results if available if results.get("phir", {}).get("success"): @@ -277,7 +236,9 @@ def bell_state() -> tuple[bool, bool]: decoded_measurements = decode_integer_results(measurements, 2) correlated = sum(1 for (a, b) in decoded_measurements if a == b) correlation_rate = correlated / len(decoded_measurements) - assert correlation_rate > 0.8, f"PHIR Bell state should be highly correlated, got {correlation_rate:.2%}" + assert ( + correlation_rate == 1.0 + ), f"PHIR Bell correlation is EXACT on a noiseless statevector, got {correlation_rate:.2%}" # ============================================================================ @@ -305,22 +266,26 @@ def boolean_or_test() -> bool: result = measure(q) # Will be True return result or False - # Test AND operation - pipeline_tester.test_function_on_both_pipelines( + # AND: measure(|0>) is deterministically 0 + results_and = pipeline_tester.test_function_on_both_pipelines( boolean_and_test, shots=10, ) + rows = results_and["hugr_llvm"]["result"]["results"] + assert [int(r) for r in rows] == [0] * 10, f"AND path measurements: {rows}" - # Test OR operation - pipeline_tester.test_function_on_both_pipelines( + # OR: measure(X|0>) is deterministically 1 + results_or = pipeline_tester.test_function_on_both_pipelines( boolean_or_test, shots=10, ) + rows = results_or["hugr_llvm"]["result"]["results"] + assert [int(r) for r in rows] == [1] * 10, f"OR path measurements: {rows}" - def test_classical_arithmetic(self, pipeline_tester: GuppyPipelineTest) -> None: - """Test basic arithmetic operations.""" + def test_classical_arithmetic(self) -> None: + """Pure-classical programs surface the entrypoint's return value + under the "return" key (no measurements, no result() calls).""" - # NOTE: This may fail on current pipelines due to limited classical support @guppy def arithmetic_test() -> int: # Simple arithmetic that doesn't depend on quantum measurements @@ -328,16 +293,11 @@ def arithmetic_test() -> int: b = 3 return a + b - results = pipeline_tester.test_function_on_both_pipelines( - arithmetic_test, - shots=5, - ) + from pecos import Guppy, sim + from pecos_rslib import state_vector - # Document current limitations - if not results.get("hugr_llvm", {}).get("success"): - pass - if not results.get("phir", {}).get("success"): - pass + results = sim(Guppy(arithmetic_test)).qubits(1).quantum(state_vector()).seed(1).run(5).to_dict() + assert list(results["return"]) == [8] * 5, f"keys: {sorted(results)}" # ============================================================================ @@ -398,10 +358,17 @@ def feedback_circuit() -> tuple[bool, bool]: return result1, measure(q2) - pipeline_tester.test_function_on_both_pipelines( + results = pipeline_tester.test_function_on_both_pipelines( feedback_circuit, shots=50, ) + rows = results["hugr_llvm"]["result"]["results"] + # The correction makes q2 EQUAL q1 on every shot -- this is the + # engine's core measurement-feedback path. + assert all(int(a) == int(b) for (a, b) in rows), f"feedback broke: {rows[:10]}" + # And H gives both branches: both outcomes must appear over 50 shots. + ones = sum(int(a) for (a, _) in rows) + assert 10 <= ones <= 40, f"H distribution off: {ones}/50 ones" # ============================================================================ @@ -451,16 +418,20 @@ def qft_2qubit() -> tuple[bool, bool]: results = pipeline_tester.test_function_on_both_pipelines(qft_2qubit, shots=100) - if results.get("hugr_llvm", {}).get("success"): - # QFT of |01⟩ should give a specific pattern - measurements = results["hugr_llvm"]["result"]["results"] - # print(f"QFT results distribution: {set(measurements)}") - # The test passes if we get results without errors - assert len(measurements) == 100 + # QFT of a computational basis state measured in the computational + # basis is UNIFORM over all four outcomes (phases are invisible + # here, so this pins the H layers and plumbing, not the CRZ angle). + measurements = results["hugr_llvm"]["result"]["results"] + assert len(measurements) == 100 + from collections import Counter + + counts = Counter(tuple(int(v) for v in row) for row in measurements) + assert set(counts) == {(0, 0), (0, 1), (1, 0), (1, 1)}, f"missing outcomes: {counts}" + assert all(10 <= c <= 45 for c in counts.values()), f"non-uniform: {counts}" def test_deutsch_josza_algorithm(self, pipeline_tester: GuppyPipelineTest) -> None: """Test Deutsch-Josza algorithm for 2-bit function.""" - from guppylang.std.quantum import cx, h, measure, qubit, x + from guppylang.std.quantum import cx, discard, h, measure, qubit, x @guppy def deutsch_josza_constant() -> tuple[bool, bool]: @@ -485,8 +456,10 @@ def deutsch_josza_constant() -> tuple[bool, bool]: h(q0) h(q1) - # Measure input qubits (ancilla can be discarded) - return measure(q0), measure(q1) + # Measure input qubits; the ancilla is discarded (linearity) + r = measure(q0), measure(q1) + discard(anc) + return r @guppy def deutsch_josza_balanced() -> tuple[bool, bool]: @@ -513,8 +486,10 @@ def deutsch_josza_balanced() -> tuple[bool, bool]: h(q0) h(q1) - # Measure input qubits - return measure(q0), measure(q1) + # Measure input qubits; the ancilla is discarded (linearity) + r = measure(q0), measure(q1) + discard(anc) + return r # Test constant function results_const = pipeline_tester.test_function_on_both_pipelines( @@ -523,10 +498,8 @@ def deutsch_josza_balanced() -> tuple[bool, bool]: ) if results_const.get("hugr_llvm", {}).get("success"): measurements = results_const["hugr_llvm"]["result"]["results"] - # Decode integer-encoded results - decoded_measurements = decode_integer_results(measurements, 2) - # For constant function, should measure |00⟩ with high probability - zeros = sum(1 for (a, b) in decoded_measurements if not a and not b) + # Rows are already per-shot (q0, q1) bit tuples + zeros = sum(1 for (a, b) in measurements if not a and not b) assert zeros > 95, f"Constant oracle should give |00⟩, got {zeros}/100" # Test balanced function @@ -536,10 +509,8 @@ def deutsch_josza_balanced() -> tuple[bool, bool]: ) if results_bal.get("hugr_llvm", {}).get("success"): measurements = results_bal["hugr_llvm"]["result"]["results"] - # Decode integer-encoded results - decoded_measurements = decode_integer_results(measurements, 2) - # For balanced function, should never measure |00⟩ - zeros = sum(1 for (a, b) in decoded_measurements if not a and not b) + # Rows are already per-shot (q0, q1) bit tuples + zeros = sum(1 for (a, b) in measurements if not a and not b) assert zeros < 5, f"Balanced oracle should not give |00⟩, got {zeros}/100" def test_grover_search(self, pipeline_tester: GuppyPipelineTest) -> None: @@ -600,4 +571,4 @@ def grover_2qubit() -> tuple[bool, bool]: decoded_measurements = decode_integer_results(measurements, 2) # Should find |11⟩ with high probability after 1 Grover iteration found = sum(1 for (a, b) in decoded_measurements if a and b) - assert found > 70, f"Grover should amplify |11⟩, got {found}/100" + assert found == 100, f"2-qubit Grover with one iteration is deterministic |11>, got {found}/100" diff --git a/python/quantum-pecos/tests/guppy/test_core_quantum_ops.py b/python/quantum-pecos/tests/guppy/test_core_quantum_ops.py index def35a697..f25cd9fa9 100644 --- a/python/quantum-pecos/tests/guppy/test_core_quantum_ops.py +++ b/python/quantum-pecos/tests/guppy/test_core_quantum_ops.py @@ -37,7 +37,7 @@ def decode_integer_results(results: list[int], n_bits: int) -> list[tuple[bool, def get_single_measurements(results: dict) -> list[int]: """Extract single-value measurements from results dict.""" - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] if not raw_measurements: return [] # Format is [[1], [0], ...] for single bool return @@ -50,7 +50,7 @@ def get_measurement_tuples(results: dict, n_bits: int) -> list[tuple[bool, ...]] """Extract measurement tuples from results, handling nested list format.""" # Get measurements - format is [[m0, m1, ...], [m0, m1, ...], ...] for tuple returns # or [[m], [m], ...] for single bool returns - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] if not raw_measurements: return [] @@ -390,7 +390,7 @@ def loop_test() -> int: results = sim(Guppy(loop_test)).qubits(10).quantum(state_vector()).seed(42).run(100).to_dict() # For int returns, measurements contains the return values - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # Handle nested list format [[2], [1], ...] for int returns if raw_measurements and isinstance(raw_measurements[0], list): measurements = [m[-1] if m else 0 for m in raw_measurements] diff --git a/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py b/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py index 2aecd7f97..aaa784083 100644 --- a/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py +++ b/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py @@ -47,7 +47,7 @@ def conditional_x_from_zero() -> bool: # Extract the return value (last measurement in each shot) # Results format: [[m1, m2], [m1, m2], ...] where m2 is the return value - measurements = results.get("measurements", []) + measurements = results["measurements"] return_values = [shot[-1] for shot in measurements] # All results should be False since q1 is |0>, so X is never applied to q2 @@ -78,13 +78,14 @@ def conditional_x_from_one() -> bool: # Run the circuit results = sim(Guppy(conditional_x_from_one)).qubits(2).quantum(state_vector()).seed(42).run(100) - # Extract measurements - measurements = results.get("measurements", []) - if not measurements and "measurement_0" in results: - measurements = results["measurement_0"] + # Extract the return value (last measurement in each shot) -- each + # row is a per-shot LIST, which is always truthy: counting rows + # instead of values made this assertion unable to fail. + measurements = results["measurements"] + return_values = [shot[-1] for shot in measurements] # All results should be True since q1 is |1>, so X is always applied to q2 - ones_count = sum(1 for m in measurements if m) + ones_count = sum(1 for m in return_values if m) assert ones_count == 100, f"Conditional X from |1> should always trigger, but got {ones_count}/100 ones" def test_measurement_feedback_entanglement(self) -> None: @@ -116,18 +117,16 @@ def measurement_feedback() -> tuple[bool, bool]: # Run the circuit results = sim(Guppy(measurement_feedback)).qubits(2).quantum(state_vector()).seed(42).run(100) - # Extract measurements - should have two measurements per shot - # Need to decode the results - measurements = [] - if "measurement_0" in results and "measurement_1" in results: - m0 = results["measurement_0"] - m1 = results["measurement_1"] - measurements = list(zip(m0, m1, strict=False)) - elif "measurements" in results: - measurements = results["measurements"] + # Extract measurements - one (m0, m1) row per shot + measurements = results["measurements"] + assert len(measurements) == 100, "should have one measurement row per shot" # Both measurements should always match mismatches = sum(1 for (a, b) in measurements if a != b) + # A zero-gate engine also produces zero mismatches (all (0,0)): + # require BOTH outcomes to actually occur across 100 shots. + firsts = {a for (a, _b) in measurements} + assert firsts == {0, 1}, f"expected both outcomes over 100 shots, got {firsts}" assert ( mismatches == 0 ), f"Measurement feedback should create perfect correlation, but got {mismatches}/100 mismatches" @@ -177,7 +176,7 @@ def teleport_one() -> bool: # Extract the return value (last measurement in each shot) # Results format: [[m0, m1, m2], ...] where m2 is the return value - measurements = results.get("measurements", []) + measurements = results["measurements"] return_values = [shot[-1] for shot in measurements] # The teleported state should be |1>, so we expect all True diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index cb3baf16c..8480c00cb 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -55,11 +55,9 @@ def test_function( ) -> dict[str, Any]: """Test a Guppy function and return results.""" if not self.backends.get("rust_backend", False): - return { - "success": False, - "error": "Rust backend not available", - "result": None, - } + # Skipping is honest; returning success=False used to + # green-pass every assertion gated behind `if success`. + pytest.skip("Rust backend not available") try: # Use sim() API @@ -70,7 +68,7 @@ def test_function( result_dict = builder.run(shots).to_dict() # Format results - measurements is [[m0], [m0], ...] or [[m0, m1], ...] - raw_measurements = result_dict.get("measurements", []) + raw_measurements = result_dict["measurements"] if raw_measurements and isinstance(raw_measurements[0], list): if len(raw_measurements[0]) == 1: # Single measurement - [[1], [0], ...] -> [1, 0, ...] @@ -87,11 +85,10 @@ def test_function( "error": None, } except Exception as e: - return { - "success": False, - "result": None, - "error": str(e), - } + # A pipeline failure must FAIL the test: the semantic assertions + # in this file are gated behind success, so returning + # success=False used to make any engine regression pass. + pytest.fail(f"guppy pipeline failed for {func}: {e}") @pytest.fixture @@ -131,9 +128,14 @@ def phase_gate_test() -> tuple[bool, bool]: return r1, r2 result = tester.test_function(phase_gate_test, shots=100) - if result["success"]: - pass - # print(f"Phase gate test results: {result['result']['results'][:10]}...") + rows = result["result"]["results"] + assert len(rows) == 100 + # H;S;H and H;T;T;H both give an exact 50/50 distribution; a broken + # S or T (e.g. silently applied as identity) gives all-zeros. + r1_ones = sum(r[0] for r in rows) + r2_ones = sum(r[1] for r in rows) + assert 30 <= r1_ones <= 70, f"S-gate distribution off: {r1_ones}/100 ones" + assert 30 <= r2_ones <= 70, f"T^2-gate distribution off: {r2_ones}/100 ones" def test_phase_gate_inverses(self, tester: ExtendedGuppyTester) -> None: """Test S† and T† (inverse phase gates).""" @@ -180,11 +182,16 @@ def rotation_test() -> tuple[bool, bool]: return r1, r2 result = tester.test_function(rotation_test, shots=100) - if result["success"]: - # RY(pi/2) on |0⟩ creates equal superposition, so roughly 50/50 distribution - # RZ just adds phase, results will vary - result["result"]["results"] - # print(f"Rotation gate test results (first 10): {results[:10]}") + rows = result["result"]["results"] + assert len(rows) == 100 + # RY(pi/2)|0> is an equal superposition: both outcomes must appear + # with a near-even split (a dropped rotation gives all-zeros). + r1_ones = sum(r[0] for r in rows) + assert 30 <= r1_ones <= 70, f"RY(pi/2) distribution off: {r1_ones}/100 ones" + # H;RZ(pi/4);H gives P(1) = sin^2(pi/8) ~ 0.146: assert the skewed + # but non-degenerate distribution. + r2_ones = sum(r[1] for r in rows) + assert 3 <= r2_ones <= 35, f"RZ(pi/4) distribution off: {r2_ones}/100 ones" # ============================================================================ @@ -207,6 +214,7 @@ def cy_cz_test() -> tuple[bool, bool, bool]: x(q1) # Set control to |1⟩ cy(q1, q2) # Apply Y to q2 since control is |1⟩ r1 = measure(q2) # Should be |1⟩ + discard(q1) # Control no longer needed (linearity) # Test CZ gate q3 = qubit() @@ -237,23 +245,22 @@ class TestQubitArrays: def test_qubit_array_creation_and_access(self, tester: ExtendedGuppyTester) -> None: """Test creating and accessing qubit arrays.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array @guppy def array_test() -> tuple[bool, bool, bool, bool]: # Create array of 4 qubits - qubits = qubit_array(4) + qubits = array(qubit() for _ in range(4)) # Apply different gates to different qubits x(qubits[1]) # Flip second qubit x(qubits[3]) # Flip fourth qubit - # Measure all - return ( - measure(qubits[0]), - measure(qubits[1]), - measure(qubits[2]), - measure(qubits[3]), - ) + # Measure all (elements cannot move out of a subscript; + # measure the array and index the copyable bits) + bits = measure_array(qubits) + return bits[0], bits[1], bits[2], bits[3] result = tester.test_function(array_test, shots=100) if result["success"]: @@ -264,29 +271,31 @@ def array_test() -> tuple[bool, bool, bool, bool]: def test_qubit_array_loops(self, tester: ExtendedGuppyTester) -> None: """Test looping over qubit arrays.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array @guppy def array_loop_test() -> int: - n = 5 - qubits = qubit_array(n) + qubits = array(qubit() for _ in range(5)) # Apply H to all qubits - for i in range(n): + for i in range(5): h(qubits[i]) # Count how many measure to |1⟩ + bits = measure_array(qubits) count = 0 - for i in range(n): - if measure(qubits[i]): + for i in range(5): + if bits[i]: count += 1 - return count result = tester.test_function(array_loop_test, shots=100) if result["success"]: - # With 5 qubits in superposition, expect average ~2.5 - counts = result["result"]["results"] - avg = sum(counts) / len(counts) + # Each shot yields the 5 measured bits; with 5 qubits in + # superposition the mean ones-per-shot is ~2.5 + rows = result["result"]["results"] + avg = sum(sum(row) for row in rows) / len(rows) assert 1.5 < avg < 3.5, f"Superposition statistics off, avg={avg}" @@ -324,8 +333,8 @@ def tuple_test() -> tuple[bool, bool]: correlated = sum(1 for (a, b) in measurements if a == b) assert correlated > 80, f"Tuple ops failed, correlation={correlated}/100" - def test_boolean_expressions(self, tester: ExtendedGuppyTester) -> None: - """Test complex boolean expressions.""" + def test_boolean_expressions(self) -> None: + """Pure-classical boolean returns surface under the "return" key.""" @guppy def boolean_expr_test() -> bool: @@ -336,11 +345,13 @@ def boolean_expr_test() -> bool: # Complex boolean expression return (a and b) or (not b and c) or (a and not c) - result = tester.test_function(boolean_expr_test, shots=10) - if result["success"]: - results = result["result"]["results"] - # (True and False) or (True and True) or (True and False) = True - assert all(r for r in results), f"Boolean expression failed: {results}" + from pecos import Guppy, sim + from pecos_rslib import state_vector + + results = sim(Guppy(boolean_expr_test)).qubits(1).quantum(state_vector()).seed(1).run(10).to_dict() + # (True and False) or (True and True) or (True and False) = True + values = [bool(v) for v in results["return"]] + assert values == [True] * 10, f"Boolean expression failed: {values}" # ============================================================================ @@ -383,40 +394,38 @@ def nested_loop_test() -> int: expected_pattern, ), f"Pattern mismatch: {shot_result}" - @pytest.mark.skip( - reason="While loops with compound conditions need more work in HUGR interpreter", - ) - def test_while_with_quantum(self, tester: ExtendedGuppyTester) -> None: - """Test while loops with quantum operations.""" + def test_while_with_quantum(self) -> None: + """A repeat-until-success while loop with a compound condition. + + The measurement count varies per shot, so raw measurements cannot + aggregate into rectangular results -- report through result(), + which is exactly what the engine's captured-result path is for. + """ + from guppylang.std.builtins import result @guppy - def while_quantum_test() -> int: + def while_quantum_test() -> None: count = 0 tries = 0 - # Keep trying until we get a |1⟩ measurement + # Keep trying until we get a |1> measurement (max 10 tries) while count == 0 and tries < 10: q = qubit() - h(q) # 50% chance of |1⟩ + h(q) # 50% chance of |1> if measure(q): count = 1 tries += 1 - return tries + result("tries", tries) - result = tester.test_function(while_quantum_test, shots=100) - if result["success"]: - # Function returns measurements, not the tries count - # Results are tuples of measurements (number varies per shot based on loop iterations) - # We can count the number of measurements to approximate tries, but can't directly verify the int return - # Just verify that we got measurement results - measurements = result["result"]["results"] - assert len(measurements) == 100, f"Expected 100 shots, got {len(measurements)}" - # Each shot should have at least one measurement (at least 1 try) - for shot_measurements in measurements: - if isinstance(shot_measurements, tuple): - assert len(shot_measurements) >= 1, "Should have at least 1 measurement per shot" - # Can't verify avg_tries since we don't get the integer return value + results = sim(Guppy(while_quantum_test)).qubits(2).quantum(state_vector()).seed(11).run(100).to_dict() + tries = [int(t) for t in results["tries"]] + assert len(tries) == 100 + # Geometric with p=1/2 capped at 10: every value in [1, 10], and + # the mean is ~2 (assert a generous but non-vacuous band). + assert all(1 <= t <= 10 for t in tries), f"out-of-range tries: {sorted(set(tries))}" + mean = sum(tries) / len(tries) + assert 1.5 < mean < 3.0, f"RUS try distribution off: mean={mean}" def test_early_return(self, tester: ExtendedGuppyTester) -> None: """Test early return from functions.""" @@ -456,17 +465,20 @@ class TestQuantumAlgorithms: def test_ghz_state_creation(self, tester: ExtendedGuppyTester) -> None: """Test GHZ state creation for multiple qubits.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array @guppy def create_ghz3() -> tuple[bool, bool, bool]: # Create 3-qubit GHZ state: (|000⟩ + |111⟩)/√2 - qubits = qubit_array(3) + qubits = array(qubit() for _ in range(3)) h(qubits[0]) cx(qubits[0], qubits[1]) cx(qubits[1], qubits[2]) - return measure(qubits[0]), measure(qubits[1]), measure(qubits[2]) + bits = measure_array(qubits) + return bits[0], bits[1], bits[2] result = tester.test_function(create_ghz3, shots=100) if result["success"]: @@ -496,7 +508,9 @@ def phase_kickback_test() -> bool: # Measure in X basis (apply H before measuring) h(control) - return measure(control) + r = measure(control) + discard(target) # linearity: target is no longer needed + return r result = tester.test_function(phase_kickback_test, shots=100) if result["success"]: @@ -613,15 +627,10 @@ def quantum_interference_test() -> bool: ones = sum(measurements_interference) prob_one = ones / len(measurements_interference) - # The S gate behavior might vary by implementation - # If S gate is not working as expected, we might get 50/50 - # For now, just verify we get measurements - assert 0 <= prob_one <= 1, f"Probability should be between 0 and 1, got {prob_one:.3f}" - - # Note: In ideal case, H-S-H on |0⟩ should give |0⟩ with high probability - # But current implementation seems to give 50/50, which suggests - # either S gate implementation differs or there's a phase issue - # This would need deeper investigation into the simulator's S gate + # H;S;H on |0> is EXACTLY 50/50: S maps |+> to (|0> + i|1>)/sqrt(2), + # and the final H yields P(1) = |1 - i|^2 / 4 = 1/2. (An S applied + # as identity would give all-zeros; as Z, all-ones.) + assert 0.4 <= prob_one <= 0.6, f"H;S;H must be 50/50, got {prob_one:.3f}" # ============================================================================ @@ -691,34 +700,32 @@ class TestPerformance: def test_many_qubits(self, tester: ExtendedGuppyTester) -> None: """Test handling many qubits.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array @guppy def many_qubits_test() -> int: # Create 10 qubits - n = 10 - qubits = qubit_array(n) + qubits = array(qubit() for _ in range(10)) # Apply H to all - for i in range(n): + for i in range(10): h(qubits[i]) # Count ones + bits = measure_array(qubits) count = 0 - for i in range(n): - if measure(qubits[i]): + for i in range(10): + if bits[i]: count += 1 - return count result = tester.test_function(many_qubits_test, shots=50) if result["success"]: - counts = result["result"]["results"] - avg = sum(counts) / len(counts) + rows = result["result"]["results"] + avg = sum(sum(row) for row in rows) / len(rows) assert 3 < avg < 7, f"Many qubit statistics off, avg={avg}" - @pytest.mark.skip( - reason="For-loop in function body returns empty results in HUGR interpreter", - ) def test_deep_circuit(self, tester: ExtendedGuppyTester) -> None: """Test deep circuit with many gates.""" diff --git a/python/quantum-pecos/tests/guppy/test_for_loop.py b/python/quantum-pecos/tests/guppy/test_for_loop.py index 0f2adc6d7..fe22bcf5a 100755 --- a/python/quantum-pecos/tests/guppy/test_for_loop.py +++ b/python/quantum-pecos/tests/guppy/test_for_loop.py @@ -21,6 +21,19 @@ def loop_with_measure() -> int: return count +def test_for_loop_with_measurements() -> None: + """The loop must run exactly 3 iterations (3 measurements per shot), and + with H per iteration both outcomes must occur across 20 seeded shots -- + a zero-iteration or frozen loop cannot satisfy either.""" + results = sim(Guppy(loop_with_measure)).qubits(10).quantum(state_vector()).seed(42).run(20).to_dict() + measurements = results["measurements"] + assert len(measurements) == 20 + for shot in measurements: + assert len(shot) == 3, f"expected 3 loop measurements, got {shot}" + outcomes = {m for shot in measurements for m in shot} + assert outcomes == {0, 1}, f"H per iteration must yield both outcomes, got {outcomes}" + + if __name__ == "__main__": os.environ["RUST_LOG"] = "pecos_hugr::engine=debug" print("Testing for-loop with measurements...") diff --git a/python/quantum-pecos/tests/guppy/test_guppy_llvm_pipeline.py b/python/quantum-pecos/tests/guppy/test_guppy_llvm_pipeline.py index 3858eb873..353a481cc 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_llvm_pipeline.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_llvm_pipeline.py @@ -139,7 +139,7 @@ def bell_state() -> tuple[bool, bool]: assert result is not None, "Should get execution results" # Measurements format is [[m0, m1], [m0, m1], ...] - measurements = result.get("measurements", []) + measurements = result["measurements"] assert len(measurements) == 100, "Should have 100 measurements" # Check correlation (Bell state should be perfectly correlated) @@ -242,7 +242,7 @@ def superposition_test() -> tuple[bool, bool, bool]: # Calculate average number of 1s # Measurements format is [[m0], [m0], ...] for single qubit # or [[m0, m1], [m0, m1], ...] for multiple qubits - measurements = result.get("measurements", []) + measurements = result["measurements"] if n_qubits == 1: ones_count = sum(m[-1] for m in measurements) diff --git a/python/quantum-pecos/tests/guppy/test_guppy_result_mechanisms.py b/python/quantum-pecos/tests/guppy/test_guppy_result_mechanisms.py index 52e6c8570..2d40ebf76 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_result_mechanisms.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_result_mechanisms.py @@ -7,11 +7,11 @@ 4. What we should expect in Selene's result stream """ -import json import tempfile from pathlib import Path import pytest +from hugr.package import Package class TestGuppyResultMechanisms: @@ -90,24 +90,11 @@ def test_compile_to_hugr(self, guppy_functions: dict) -> None: assert hugr_bytes is not None, f"{name} should compile to HUGR bytes" assert len(hugr_bytes) > 0, f"{name} HUGR bytes should not be empty" - # Parse HUGR to verify structure - hugr_str = hugr_bytes.decode("utf-8") - - # Handle HUGR envelope format - if hugr_str.startswith("HUGRiHJv"): - json_start = hugr_str.find("{", 9) - assert json_start != -1, "HUGR envelope should contain JSON" - hugr_str = hugr_str[json_start:] - - # Verify it's valid JSON - try: - hugr_json = json.loads(hugr_str) - except json.JSONDecodeError as e: - pytest.fail(f"{name} HUGR is not valid JSON: {e}") - - # Verify basic HUGR structure - assert isinstance(hugr_json, dict), "HUGR should be a JSON object" - assert "nodes" in hugr_json or "modules" in hugr_json, "HUGR should contain nodes or modules" + # Load the binary Model envelope and verify basic structure + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, f"{name} HUGR should contain at least one module" + total_nodes = sum(1 for module in pkg.modules for _ in module.nodes()) + assert total_nodes > 0, f"{name} HUGR should contain at least one node" def test_hugr_contains_operations(self, guppy_functions: dict) -> None: """Test that HUGR contains expected quantum and result operations.""" @@ -118,37 +105,23 @@ def test_hugr_contains_operations(self, guppy_functions: dict) -> None: for name, func in guppy_functions.items(): hugr_bytes = compile_guppy_to_hugr(func) - hugr_str = hugr_bytes.decode("utf-8") - - # Handle HUGR envelope format - if hugr_str.startswith("HUGRiHJv"): - json_start = hugr_str.find("{", 9) - hugr_str = hugr_str[json_start:] + pkg = Package.from_bytes(hugr_bytes) - hugr_json = json.loads(hugr_str) + # Map of ExtOp gate name -> count across all modules. + ops = self._count_operations(pkg) - # Count different types of operations - ops = self._count_operations(hugr_json) + # Every fixture builds a Bell pair, so the compiled HUGR must contain + # the concrete Hadamard and CX gate ops plus a measurement -- not + # merely "some" nodes. + assert ops.get("H", 0) >= 1, f"{name} HUGR should contain an H gate, got {ops}" + assert ops.get("CX", 0) >= 1, f"{name} HUGR should contain a CX gate, got {ops}" + assert ops.get("MeasureFree", 0) >= 1, f"{name} HUGR should contain a measurement, got {ops}" - # Check if HUGR contains any operations at all - total_ops = sum(ops.values()) - - # If we found operations but no quantum ops, it might be a format issue - # The important thing is that the HUGR compiles and has structure - if total_ops == 0: - # Try to check if the HUGR has nodes which indicates it has content - has_nodes = "nodes" in hugr_json and len(hugr_json.get("nodes", [])) > 0 - has_modules = "modules" in hugr_json and len(str(hugr_json.get("modules", ""))) > 100 - - if not (has_nodes or has_modules): - pytest.fail( - f"{name} HUGR seems empty - no operations or nodes found", - ) - - # Functions with result() should have result/output operations + # Functions that tag outputs via result() must keep the result ops + # (guppy lowers result() to tket.result `result_bool`/`result_int`). if "result_tags" in name or "mixed" in name: - # We're being more lenient here since format may vary - pass # Just verify compilation succeeded above + result_ops = sum(count for op_name, count in ops.items() if op_name.startswith("result")) + assert result_ops >= 1, f"{name} HUGR should retain result() ops, got {ops}" def test_compile_to_llvm(self, guppy_functions: dict) -> None: """Test that HUGR compiles to LLVM successfully.""" @@ -276,39 +249,16 @@ def test_expected_output_formats(self) -> None: for type_name in format_info["expected_types"]: assert type_name in valid_types, f"{func_name} has invalid type: {type_name}" - def _count_operations(self, hugr_json: dict) -> dict[str, int]: - """Count different types of operations in HUGR JSON.""" - counts = { - "quantum": 0, - "result": 0, - "output": 0, - "io": 0, - } - - def search(obj: object) -> None: - if isinstance(obj, dict): - if "op" in obj: - op_str = str(obj["op"]).lower() - - # Count quantum operations - if any(q in op_str for q in ["quantum", "h", "cx", "measure"]): - counts["quantum"] += 1 - - # Count result/output operations - if "result" in op_str: - counts["result"] += 1 - if "output" in op_str: - counts["output"] += 1 - if "io" in op_str or "print" in op_str: - counts["io"] += 1 - - for value in obj.values(): - search(value) - elif isinstance(obj, list): - for item in obj: - search(item) - - search(hugr_json) + def _count_operations(self, pkg: Package) -> dict[str, int]: + """Count ExtOp gate names across all modules in the HUGR package.""" + counts: dict[str, int] = {} + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + op = module[n].op + op_def = getattr(op, "_op_def", None) + if op_def is not None: + counts[op_def.name] = counts.get(op_def.name, 0) + 1 return counts diff --git a/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py b/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py index 8ae065406..0aecf18c6 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py @@ -56,8 +56,8 @@ def test_basic_build_and_run(self) -> None: assert len(results2["measurement_0"]) == 10 else: # Fallback to old format - measurements1 = results1.get("measurements", results1.get("result", [])) - measurements2 = results2.get("measurements", results2.get("result", [])) + measurements1 = results1["measurements"] + measurements2 = results2["measurements"] assert len(measurements1) == 10 assert len(measurements2) == 10 @@ -66,7 +66,7 @@ def test_direct_run(self) -> None: results = sim(self.single_qubit).qubits(10).quantum(state_vector()).run(10).to_dict() # Check that we have measurement results - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 assert all(r in [0, 1] for r in measurements) @@ -86,10 +86,7 @@ def test_builder_methods(self) -> None: sim_obj = builder.build() results = sim_obj.run(100) - measurements = results.get( - "measurements", - results.get("measurement_0", results.get("result", [])), - ) + measurements = results["measurements"] assert measurements is not None assert len(measurements) > 0 assert len(measurements) == 100 # 100 shots, each with integer-encoded 2 qubits @@ -99,14 +96,9 @@ def test_seeded_reproducibility(self) -> None: # Run with same seed twice results1 = sim(self.single_qubit).qubits(10).quantum(state_vector()).seed(12345).run(100) results2 = sim(self.single_qubit).qubits(10).quantum(state_vector()).seed(12345).run(100) - measurements1 = results1.get( - "measurements", - results1.get("measurement_0", results1.get("result", [])), - ) - measurements2 = results2.get( - "measurements", - results2.get("measurement_0", results2.get("result", [])), - ) + measurements1 = results1["measurements"] + measurements2 = results2["measurements"] + assert len(measurements1) == 100, "seeded run should produce one row per shot" assert measurements1 == measurements2 def test_config_dict(self) -> None: @@ -117,7 +109,7 @@ def test_config_dict(self) -> None: assert len(results["measurement_0"]) == 50 assert len(results["measurement_1"]) == 50 else: - measurements = results.get("measurements", results.get("result", [])) + measurements = results["measurements"] assert len(measurements) == 50 def test_bell_state_correlation(self) -> None: @@ -125,7 +117,7 @@ def test_bell_state_correlation(self) -> None: results = sim(self.bell_state).qubits(10).quantum(state_vector()).seed(42).run(1000).to_dict() # Measurements format is [[m0, m1], [m0, m1], ...] - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] correlated = sum(1 for m in raw_measurements if m[0] == m[1]) assert correlated == len( raw_measurements, @@ -149,7 +141,7 @@ def test_keep_intermediate_files(self) -> None: # Run simulation results = sim_obj.run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 @@ -261,7 +253,7 @@ def measure_without_gates() -> bool: # First run - all measurements should be 0 since qubit starts in |0⟩ results1 = sim_obj.run(100) - measurements1 = results1.to_dict().get("measurements", []) + measurements1 = results1.to_dict()["measurements"] assert all( m == 0 or m == [0] or m == (0,) or m is False for m in measurements1 ), f"Expected all measurements to be 0, got: {measurements1[:5]}..." @@ -271,7 +263,7 @@ def measure_without_gates() -> bool: # After reset, qubit should be back in |0⟩, so all measurements should still be 0 results2 = sim_obj.run(100) - measurements2 = results2.to_dict().get("measurements", []) + measurements2 = results2.to_dict()["measurements"] assert all( m == 0 or m == [0] or m == (0,) or m is False for m in measurements2 ), f"After reset, expected all measurements to be 0, got: {measurements2[:5]}..." diff --git a/python/quantum-pecos/tests/guppy/test_guppy_simple_pipeline.py b/python/quantum-pecos/tests/guppy/test_guppy_simple_pipeline.py index 3a055889a..71c6656cd 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_simple_pipeline.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_simple_pipeline.py @@ -45,7 +45,7 @@ def quantum_coin() -> bool: result = sim(Guppy(quantum_coin)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() # Should have measurement results - raw_measurements = result.get("measurements", []) + raw_measurements = result["measurements"] values = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(values) == 10 # Hadamard should give mix of 0s and 1s diff --git a/python/quantum-pecos/tests/guppy/test_guppy_with_results.py b/python/quantum-pecos/tests/guppy/test_guppy_with_results.py index 1588d64ba..db6a891a9 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_with_results.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_with_results.py @@ -4,11 +4,11 @@ that Selene can extract from the result stream. """ -import json import tempfile from pathlib import Path import pytest +from hugr.package import Package class TestGuppyWithResults: @@ -280,25 +280,12 @@ def test_with_outputs() -> None: hugr_bytes = compile_guppy_to_hugr(test_with_outputs) - # Parse HUGR to check for output operations - hugr_str = hugr_bytes.decode("utf-8") + # Load the binary Model envelope and count result() operations + pkg = Package.from_bytes(hugr_bytes) + output_ops = self._count_output_operations(pkg) - # Handle HUGR envelope format if present - if hugr_str.startswith("HUGRiHJv"): - json_start = hugr_str.find("{", 9) - if json_start != -1: - hugr_str = hugr_str[json_start:] - - try: - hugr_json = json.loads(hugr_str) - except json.JSONDecodeError as e: - pytest.fail(f"HUGR is not valid JSON: {e}") - - # Count output-related operations - output_ops = self._count_output_operations(hugr_json) - - # Should have some output/result/io operations - assert output_ops > 0, "HUGR should contain output/result operations" + # test_with_outputs tags two results, so both tket.result ops must survive. + assert output_ops >= 2, f"HUGR should retain the result() ops, found {output_ops}" def test_save_hugr_artifacts(self, check_guppy_imports: dict) -> None: """Test saving HUGR compilation artifacts for inspection.""" @@ -334,47 +321,27 @@ def simple_quantum() -> bool: assert hugr_file.exists(), "HUGR file should be created" assert hugr_file.stat().st_size > 0, "HUGR file should not be empty" - # Parse and save formatted JSON - hugr_str = hugr_bytes.decode("utf-8") - if hugr_str.startswith("HUGRiHJv"): - json_start = hugr_str.find("{", 9) - if json_start != -1: - hugr_str = hugr_str[json_start:] + # Load the saved artifact to confirm it is a valid HUGR + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "Saved HUGR should contain at least one module" - try: - hugr_json = json.loads(hugr_str) - formatted_file = tmpdir_path / "simple_quantum_formatted.json" - formatted_file.write_text(json.dumps(hugr_json, indent=2)) + def _count_output_operations(self, pkg: Package) -> int: + """Count ``result()`` operations across all modules in the HUGR package. - assert formatted_file.exists(), "Formatted JSON should be created" - assert formatted_file.stat().st_size > 0, "Formatted JSON should not be empty" - - # Verify JSON structure - assert isinstance(hugr_json, dict), "HUGR should be a JSON object" - - except json.JSONDecodeError: - # If not JSON, that's okay - just test raw bytes were saved - pass - - def _count_output_operations(self, hugr_json: dict) -> int: - """Count output-related operations in HUGR JSON.""" + guppy lowers ``result(tag, value)`` to concrete ``tket.result`` extension + ops (``result_bool``, ``result_int``, ...). Counting these -- rather than + any output/measurement node -- verifies the ``result()`` tags actually + survived compilation, which is the point of these tests. + """ count = 0 - - def search(obj: object) -> None: - nonlocal count - if isinstance(obj, dict): - if "op" in obj: - op_str = str(obj["op"]).lower() - if any(term in op_str for term in ["output", "result", "return", "io"]): - count += 1 - - for value in obj.values(): - search(value) - elif isinstance(obj, list): - for item in obj: - search(item) - - search(hugr_json) + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + op = module[n].op + op_def = getattr(op, "_op_def", None) + name = op_def.name if op_def is not None else type(op).__name__ + if name.startswith("result"): + count += 1 return count diff --git a/python/quantum-pecos/tests/guppy/test_hugr_compilation.py b/python/quantum-pecos/tests/guppy/test_hugr_compilation.py index 89afb372a..bc2c779ac 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_compilation.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_compilation.py @@ -193,12 +193,10 @@ def simple_circuit() -> bool: assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" assert isinstance(hugr_bytes, bytes), "Should return bytes" - # Check for HUGR format markers - hugr_str = hugr_bytes.decode("utf-8") - is_hugr_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") or "{" in hugr_str[:100] + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - assert is_hugr_envelope or is_json, "HUGR output should be envelope format or JSON" + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "HUGR bytes should load as a valid HUGR" class TestLLVMIRPatterns: diff --git a/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py b/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py index b1e810606..0cdddf93f 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py @@ -4,6 +4,7 @@ for the same HUGR input. """ +from collections import Counter from pathlib import Path import pytest @@ -99,18 +100,25 @@ def compare_compilers( if selene_qis == rust_qis: return True, "QIS calls match exactly" - # If not exact match, provide diagnostic info - selene_set = set(selene_qis) - rust_set = set(rust_qis) + selene_counts = Counter(selene_qis) + rust_counts = Counter(rust_qis) + selene_set = set(selene_counts) + rust_set = set(rust_counts) - only_selene = selene_set - rust_set - only_rust = rust_set - selene_set + # LLVM 21 can peel runtime loops, duplicating static call sites while + # preserving the dynamic behavior. In that case exact call-site + # multiplicity is not a robust parity signal. + if selene_set == rust_set and ("llvm.loop.peeled.count" in selene_ir or "llvm.loop.peeled.count" in rust_ir): + return True, "QIS call set matches; static call counts differ only after LLVM loop peeling" + + only_selene = selene_counts - rust_counts + only_rust = rust_counts - selene_counts msg = "QIS calls differ:\n" if only_selene: - msg += f" Only in Selene: {only_selene}\n" + msg += f" Only in Selene: {dict(only_selene)}\n" if only_rust: - msg += f" Only in Rust: {only_rust}\n" + msg += f" Only in Rust: {dict(only_rust)}\n" return False, msg diff --git a/python/quantum-pecos/tests/guppy/test_hugr_structure.py b/python/quantum-pecos/tests/guppy/test_hugr_structure.py index bbe4cda71..a2dfe779e 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_structure.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_structure.py @@ -1,13 +1,10 @@ """Test to understand HUGR 0.13 structure from guppylang.""" -import json -import tempfile - import pytest def test_hugr_json_structure() -> None: - """Examine HUGR JSON structure from guppylang.""" + """Examine HUGR structure from guppylang.""" try: from guppylang import guppy from guppylang.std.quantum import h, measure, qubit @@ -20,34 +17,9 @@ def simple_circuit() -> bool: h(q) return measure(q) - # Compile to HUGR - hugr = simple_circuit.compile() - - # Get JSON/string representation (use to_str if available) - if hasattr(hugr, "to_str"): - hugr_str = hugr.to_str() - # Check if it's the envelope format with header - if hugr_str.startswith("HUGRiHJv"): - # Skip header (8 bytes), format byte (1 byte), and find JSON start - json_start = hugr_str.find("{", 9) - if json_start != -1: - hugr_str = hugr_str[json_start:] - else: - msg = "Could not find JSON start in HUGR envelope" - raise ValueError(msg) - else: - hugr_str = hugr.to_json() - - hugr_dict = json.loads(hugr_str) - - if "modules" in hugr_dict: - for _i, module in enumerate(hugr_dict["modules"]): - if "nodes" in module: - # Print first few nodes - for _j, _node in enumerate(module["nodes"][:5]): - - pass + # Compile to HUGR (a hugr.package.Package) + pkg = simple_circuit.compile() - # Save to file for inspection - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(hugr_dict, f, indent=2) + # Inspect the Package structure directly + assert len(pkg.modules) >= 1, "HUGR should contain at least one module" + assert sum(1 for _ in pkg.modules[0].nodes()) > 0, "First module should contain at least one node" diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index b4b27d46f..e811012c1 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -61,3 +61,60 @@ def hadamard_test() -> bool: assert "@___qalloc()" in llvm_ir, "Should have Selene qubit allocation" assert "@___rxy" in llvm_ir or "@___rz" in llvm_ir, "Should have Selene rotation gates for H" assert "@___lazy_measure" in llvm_ir, "Should have Selene measurement" + + +def test_trace_metadata_helper_uses_public_symbol() -> None: + """Test that declared trace metadata helpers compile to the public FFI symbol.""" + try: + from guppylang import guppy + from guppylang.std.builtins import owned + from guppylang.std.quantum import h, measure, qubit + from pecos_rslib import compile_hugr_to_qis + except ImportError as e: + pytest.skip(f"Required imports not available: {e}") + + @guppy.declare + def pecos_qis_trace_metadata_qubit_hugr(q: qubit @ owned, key: str, value: str) -> qubit: ... + + @guppy + def metadata_probe() -> None: + q = qubit() + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_kind", "szz_host") + h(q) + _ = measure(q) + + llvm_ir = compile_hugr_to_qis(metadata_probe.compile().to_bytes()) + + assert "@pecos_qis_trace_metadata_qubit_hugr" in llvm_ir + assert "@__hugr__.pecos_qis_trace_metadata_qubit_hugr" not in llvm_ir + + +def test_runtime_barrier_pair_helper_uses_public_symbol() -> None: + """Test that two-qubit runtime-barrier helpers compile to the public FFI symbol.""" + try: + from guppylang import guppy + from guppylang.std.builtins import owned + from guppylang.std.quantum import cx, h, measure, qubit + from pecos_rslib import compile_hugr_to_qis + except ImportError as e: + pytest.skip(f"Required imports not available: {e}") + + @guppy.declare + def pecos_qis_runtime_barrier_qubits2_hugr( + q0: qubit @ owned, + q1: qubit @ owned, + ) -> tuple[qubit, qubit]: ... + + @guppy + def barrier_pair_probe() -> tuple[bool, bool]: + q0 = qubit() + q1 = qubit() + h(q0) + q0, q1 = pecos_qis_runtime_barrier_qubits2_hugr(q0, q1) + cx(q0, q1) + return measure(q0), measure(q1) + + llvm_ir = compile_hugr_to_qis(barrier_pair_probe.compile().to_bytes()) + + assert "@pecos_qis_runtime_barrier_qubits2_hugr" in llvm_ir + assert "@__hugr__.pecos_qis_runtime_barrier_qubits2_hugr" not in llvm_ir diff --git a/python/quantum-pecos/tests/guppy/test_isolated_quantum_ops.py b/python/quantum-pecos/tests/guppy/test_isolated_quantum_ops.py index 5b6eab831..9d8e7bcce 100644 --- a/python/quantum-pecos/tests/guppy/test_isolated_quantum_ops.py +++ b/python/quantum-pecos/tests/guppy/test_isolated_quantum_ops.py @@ -55,7 +55,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - assert len(results.get("measurements", results.get("measurement_0", []))) == 10 + assert len(results["measurements"]) == 10 def test_single_x_gate(self) -> None: """Test just X gate.""" @@ -67,7 +67,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - assert all(r for r in results.get("measurements", results.get("measurement_0", []))) + assert all(r for r in results["measurements"]) def test_single_y_gate(self) -> None: """Test just Y gate.""" @@ -79,7 +79,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - assert all(r for r in results.get("measurements", results.get("measurement_0", []))) + assert all(r for r in results["measurements"]) def test_single_z_gate(self) -> None: """Test just Z gate.""" @@ -91,7 +91,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # Z on |0> -> |0>, so all measurements should be 0 assert all(m[0] == 0 for m in measurements) @@ -107,7 +107,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # X on |0> -> |1>, S-Sdg is identity, so all measurements should be 1 assert all(m[0] == 1 for m in measurements) @@ -123,7 +123,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # X on |0> -> |1>, T-Tdg is identity, so all measurements should be 1 assert all(m[0] == 1 for m in measurements) @@ -137,7 +137,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # RX(pi) on |0> -> |1>, so all measurements should be 1 assert all(m[0] == 1 for m in measurements) @@ -151,7 +151,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # RY(pi) on |0> -> |1>, so all measurements should be 1 assert all(m[0] == 1 for m in measurements) @@ -165,7 +165,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # RZ on |0> -> |0>, so all measurements should be 0 assert all(m[0] == 0 for m in measurements) @@ -182,7 +182,7 @@ def test() -> tuple[bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # Should get [1, 1] for both qubits (X on q1, then CX flips q2) - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [1, 1] for m in measurements) def test_two_qubit_cy(self) -> None: @@ -198,7 +198,7 @@ def test() -> tuple[bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # CY with control=1 should flip target - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [1, 1] for m in measurements) def test_two_qubit_cz(self) -> None: @@ -215,7 +215,7 @@ def test() -> tuple[bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # Both qubits should be |1> (CZ only adds phase, no bit flip) - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [1, 1] for m in measurements) def test_two_qubit_ch(self) -> None: @@ -230,7 +230,7 @@ def test() -> tuple[bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # CH with control=0 does nothing, both stay |0> - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [0, 0] for m in measurements) def test_toffoli(self) -> None: @@ -248,7 +248,7 @@ def test() -> tuple[bool, bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # Both controls at |1>, target flips to |1> - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [1, 1, 1] for m in measurements) def test_reset_operation(self) -> None: @@ -262,7 +262,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # Reset should bring |1> back to |0> assert all(m[0] == 0 for m in measurements) @@ -279,7 +279,7 @@ def test() -> bool: return measure(q2) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # After discard, X on q2 gives |1> assert all(m[0] == 1 for m in measurements) @@ -310,7 +310,7 @@ def test() -> tuple[bool, bool, bool, bool]: return result1, result2, result3, result4 results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", []) + measurements = results["measurements"] for m in measurements: # m is now a list like [r1, r2, r3, r4] diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index 752249669..7ff726727 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -67,7 +67,7 @@ def get_measurements(results: dict, _expected_count: int = 1) -> list: List of measurements (either single values or tuples) """ # Get measurements from new format - [[m0], [m1], ...] or [[m0, m1], [m0, m1], ...] - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] if not raw_measurements: return [] @@ -585,15 +585,14 @@ def error_handling_test() -> tuple[bool, bool]: assert len(success_zeros) > 150, f"H gate should produce ~50% 0s, got {len(success_zeros)}/{len(success_cases)}" assert len(success_ones) > 150, f"H gate should produce ~50% 1s, got {len(success_ones)}/{len(success_cases)}" - # Check error cases (X gate should give all 1s) - # Note: Guppy conditional branching has a known issue where gates in - # conditional branches may not execute correctly. This test verifies - # measurements are valid; full X gate verification may fail until fixed. - error_zeros = [m for m in error_cases if m[1] == 0] + # Check error cases: the error branch applies X, so m2 must be 1 + # in EVERY error-case shot. (This assertion spent years disabled + # behind a "known conditional-branch issue" note while a tautology + # kept the test green; branch execution is fixed.) error_ones = [m for m in error_cases if m[1] == 1] - assert len(error_ones) + len(error_zeros) == len( + assert len(error_ones) == len( error_cases, - ), "All error cases should have valid m2 measurements" + ), f"X branch must force m2=1 in all {len(error_cases)} error cases, got {len(error_ones)}" def test_projective_measurement(self) -> None: """Test measurement collapse behavior.""" diff --git a/python/quantum-pecos/tests/guppy/test_multi_module_handling.py b/python/quantum-pecos/tests/guppy/test_multi_module_handling.py index e001b70a4..51c71becb 100644 --- a/python/quantum-pecos/tests/guppy/test_multi_module_handling.py +++ b/python/quantum-pecos/tests/guppy/test_multi_module_handling.py @@ -10,6 +10,7 @@ import pytest from guppylang import GuppyModule, guppy +from hugr.package import Package from pecos_rslib import compile_hugr_to_qis as rust_compile from selene_hugr_qis_compiler import compile_to_llvm_ir as selene_compile @@ -20,40 +21,24 @@ from guppylang.std.quantum import cx, h, measure, qubit -def count_modules_in_hugr(hugr_str: str) -> tuple[int, list[str]]: - """Count modules and extract their function names from HUGR string. +def count_modules_in_hugr(pkg: Package) -> tuple[int, list[str]]: + """Count modules and extract their function names from a HUGR package. Args: - hugr_str: HUGR in string format (may be JSON or binary-prefixed) + pkg: Compiled HUGR as a hugr.package.Package Returns: (module_count, list_of_function_names) """ - try: - # HUGR string format seems to have a binary prefix, try to extract JSON - if hugr_str.startswith("HUGRi"): - # Find the JSON part after the binary prefix - json_start = hugr_str.find('{"modules"') - if json_start == -1: - return 0, [] - hugr_str = hugr_str[json_start:] - - data = json.loads(hugr_str) - modules = data.get("modules", []) - - # Extract function names from all modules - function_names = [ - node["name"] - for module in modules - for node in module.get("nodes", []) - if node.get("op") == "FuncDefn" and "name" in node and node["name"] != "__main__" - ] - - return len(modules), function_names - except (json.JSONDecodeError, KeyError, TypeError) as e: - print(f"Failed to parse HUGR: {e}") - print(f"First 200 chars: {hugr_str[:200]}") - return 0, [] + function_names: list[str] = [] + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + op = module[n].op + if type(op).__name__ == "FuncDefn" and op.f_name != "__main__": + function_names.append(op.f_name) + + return len(pkg.modules), function_names def extract_function_calls_from_llvm(llvm_ir: str) -> set[str]: @@ -92,11 +77,10 @@ def single_hadamard() -> bool: h(q) return measure(q) - hugr = single_hadamard.compile() - hugr_json = hugr.to_str() if hasattr(hugr, "to_str") else str(hugr) + pkg = single_hadamard.compile() # Analyze the HUGR structure - module_count, function_names = count_modules_in_hugr(hugr_json) + module_count, function_names = count_modules_in_hugr(pkg) print(f"Single module test - Modules: {module_count}, Functions: {function_names}") assert module_count >= 1, "Should have at least one module" @@ -126,15 +110,12 @@ def single_qubit_test() -> bool: return measure(q) # Compile each function separately - bell_hugr = create_bell_pair.compile() - single_hugr = single_qubit_test.compile() + bell_pkg = create_bell_pair.compile() + single_pkg = single_qubit_test.compile() # Analyze each HUGR structure - bell_hugr_str = bell_hugr.to_str() if hasattr(bell_hugr, "to_str") else str(bell_hugr) - single_hugr_str = single_hugr.to_str() if hasattr(single_hugr, "to_str") else str(single_hugr) - - bell_modules, bell_functions = count_modules_in_hugr(bell_hugr_str) - single_modules, single_functions = count_modules_in_hugr(single_hugr_str) + bell_modules, bell_functions = count_modules_in_hugr(bell_pkg) + single_modules, single_functions = count_modules_in_hugr(single_pkg) print(f"Bell pair - Modules: {bell_modules}, Functions: {bell_functions}") print(f"Single qubit - Modules: {single_modules}, Functions: {single_functions}") @@ -170,7 +151,7 @@ def test_function() -> tuple[bool, bool]: hugr_str = hugr.to_str() if hasattr(hugr, "to_str") else str(hugr) # Analyze HUGR structure - module_count, function_names = count_modules_in_hugr(hugr_str) + module_count, function_names = count_modules_in_hugr(hugr) print(f"HUGR Analysis - Modules: {module_count}, Functions: {function_names}") # Compile with both compilers diff --git a/python/quantum-pecos/tests/guppy/test_noise_models.py b/python/quantum-pecos/tests/guppy/test_noise_models.py index cd80f877f..b79d40cf7 100644 --- a/python/quantum-pecos/tests/guppy/test_noise_models.py +++ b/python/quantum-pecos/tests/guppy/test_noise_models.py @@ -32,7 +32,7 @@ def deterministic_circuit() -> bool: results = sim(deterministic_circuit).qubits(10).quantum(state_vector()).seed(42).run(10).to_dict() # Should always measure |1⟩ - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert all(r == 1 for r in measurements) @@ -57,7 +57,7 @@ def simple_circuit() -> bool: # High depolarizing probability to see effect results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # With 0.2 depolarizing on X gate, we should see some 0s @@ -86,7 +86,7 @@ def simple_circuit() -> bool: results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # Should see some errors @@ -111,7 +111,7 @@ def simple_circuit() -> bool: sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise_builder).seed(42).run(100).to_dict() ) - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # Should see some errors but not too many @@ -146,8 +146,8 @@ def bell_circuit() -> tuple[bool, bool]: results_noisy = sim(bell_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() # Extract measurements - format is [[m0, m1], [m0, m1], ...] - clean_measurements = results_clean.get("measurements", []) - noisy_measurements = results_noisy.get("measurements", []) + clean_measurements = results_clean["measurements"] + noisy_measurements = results_noisy["measurements"] # Check correlations clean_corr = sum(1 for m in clean_measurements if m[0] == m[1]) @@ -194,8 +194,8 @@ def simple_x_circuit() -> bool: results2 = sim(simple_x_circuit).qubits(10).quantum(state_vector()).noise(noise2).seed(43).run(10).to_dict() - raw_measurements1 = results1.get("measurements", []) - raw_measurements2 = results2.get("measurements", []) + raw_measurements1 = results1["measurements"] + raw_measurements2 = results2["measurements"] measurements1 = [m[-1] if isinstance(m, list) else m for m in raw_measurements1] measurements2 = [m[-1] if isinstance(m, list) else m for m in raw_measurements2] @@ -220,7 +220,7 @@ def multi_gate_circuit() -> bool: results = sim(multi_gate_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # H followed by X should give |1⟩ without noise @@ -243,7 +243,7 @@ def simple_circuit() -> bool: results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # X gate gives |1⟩, but measurement errors should flip some diff --git a/python/quantum-pecos/tests/guppy/test_program_fuzz.py b/python/quantum-pecos/tests/guppy/test_program_fuzz.py new file mode 100644 index 000000000..a3439a109 --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_program_fuzz.py @@ -0,0 +1,172 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generative program fuzzing for the HUGR engine. + +Each seed generates a random classical program that is simultaneously valid +Python and valid guppy: plain Python `exec` computes the reference value, +and the guppy version gates an X on a fresh qubit iff the engine computes +the same value. Every divergence -- a wrong arithmetic result, a +mis-executed loop, a dropped branch -- fails loudly as a 0 measurement. + +Generated programs stay in Euclidean-safe territory (every reduction uses a +positive literal modulus, divisions use positive literal divisors, shift +operands are pre-masked non-negative) BY DESIGN: plain Python is only a +valid reference where its semantics coincide with HUGR's. The regimes +where they diverge -- negative divisors (floor vs Euclidean with an +UNSIGNED divisor bit pattern), arithmetic-vs-logical right shift on +negative operands, and shifts past the width -- are pinned separately with +hand-derived spec expectations in test_semantic_sweep.py. +""" + +import importlib.util +import random +import sys +from pathlib import Path + +import pytest +from pecos import Guppy, sim +from pecos_rslib import state_vector + +MOD = 9973 +SEEDS = range(20) + + +class _BodyGenerator: + """Generate a random classical statement body over int variables.""" + + def __init__(self, rng: random.Random) -> None: + self.rng = rng + self.lines: list[str] = [] + self.vars = ["v0", "v1", "v2"] + self.loop_depth = 0 + + def operand(self) -> str: + if self.rng.random() < 0.3: + return str(self.rng.randint(0, 99)) + return self.rng.choice(self.vars) + + def emit(self, line: str, indent: int) -> None: + self.lines.append(" " * indent + line) + + def assign(self, indent: int) -> None: + target = self.rng.choice(self.vars) + kind = self.rng.random() + a, b = self.operand(), self.operand() + if kind < 0.45: + op = self.rng.choice(["+", "-", "*"]) + expr = f"({a} {op} {b}) % {MOD}" + elif kind < 0.7: + op = self.rng.choice(["//", "%"]) + divisor = self.rng.randint(2, 9) + expr = f"({a} {op} {divisor}) % {MOD}" + else: + op = self.rng.choice(["<<", ">>"]) + shift = self.rng.randint(0, 6) + expr = f"(({a} % 64) {op} {shift}) % {MOD}" + self.emit(f"{target} = {expr}", indent) + + def branch(self, indent: int) -> None: + a, b = self.rng.choice(self.vars), self.rng.choice(self.vars) + cmp_op = self.rng.choice(["<", "<=", ">", ">=", "==", "!="]) + self.emit(f"if {a} {cmp_op} {b}:", indent) + self.assign(indent + 1) + if self.rng.random() < 0.5: + self.emit("else:", indent) + self.assign(indent + 1) + + def loop(self, indent: int) -> None: + self.loop_depth += 1 + var = f"i{self.loop_depth}" + bound = self.rng.randint(0, 4) + self.emit(f"for {var} in range({bound}):", indent) + target = self.rng.choice(self.vars) + self.emit(f"{target} = ({target} + {var} + 1) % {MOD}", indent + 1) + if self.loop_depth < 2 and self.rng.random() < 0.4: + self.loop(indent + 1) + elif self.rng.random() < 0.4: + self.assign(indent + 1) + + def while_loop(self, indent: int) -> None: + count = self.rng.randint(1, 5) + target = self.rng.choice(self.vars) + self.emit(f"w = {count}", indent) + self.emit("while w > 0:", indent) + self.emit(f"{target} = ({target} * 3 + w) % {MOD}", indent + 1) + self.emit("w = w - 1", indent + 1) + + def generate(self) -> str: + for i, name in enumerate(self.vars): + self.emit(f"{name} = {self.rng.randint(0, MOD - 1)}", 1) + del i + for _ in range(self.rng.randint(4, 8)): + pick = self.rng.random() + if pick < 0.45: + self.assign(1) + elif pick < 0.65: + self.branch(1) + elif pick < 0.85: + self.loop(1) + else: + self.while_loop(1) + self.emit(f"acc = (v0 + 31 * v1 + 977 * v2) % {MOD}", 1) + return "\n".join(self.lines) + + +def _reference_value(body: str) -> int: + """Execute the generated body as plain Python and return acc.""" + source = "def _ref():\n" + body + "\n return acc\n" + namespace: dict = {} + exec(source, namespace) # noqa: S102 -- fuzz reference evaluation of generated code + return namespace["_ref"]() + + +def _load_guppy_module(tmp_path: Path, seed: int, source: str): + path = tmp_path / f"fuzz_prog_{seed}.py" + path.write_text(source) + spec = importlib.util.spec_from_file_location(f"fuzz_prog_{seed}", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(spec.name, None) + return module + + +@pytest.mark.parametrize("seed", SEEDS) +def test_fuzzed_program_matches_python_reference(seed: int, tmp_path: Path) -> None: + rng = random.Random(seed) + body = _BodyGenerator(rng).generate() + expected = _reference_value(body) + + source = ( + "from guppylang import guppy\n" + "from guppylang.std.quantum import measure, qubit, x\n" + "\n" + "\n" + "@guppy\n" + "def fuzz_prog() -> bool:\n" + " q = qubit()\n" + f"{body}\n" + f" if acc == {expected}:\n" + " x(q)\n" + " return measure(q)\n" + ) + module = _load_guppy_module(tmp_path, seed, source) + + results = sim(Guppy(module.fuzz_prog)).qubits(2).quantum(state_vector()).seed(7).run(2).to_dict() + raw = results["measurements"] + values = [m[-1] if isinstance(m, list) else m for m in raw] + assert values == [1, 1], f"seed {seed}: engine diverged from reference\n{source}" diff --git a/python/quantum-pecos/tests/guppy/test_python_side_compilation.py b/python/quantum-pecos/tests/guppy/test_python_side_compilation.py index 3d59350b1..147588a5a 100644 --- a/python/quantum-pecos/tests/guppy/test_python_side_compilation.py +++ b/python/quantum-pecos/tests/guppy/test_python_side_compilation.py @@ -112,28 +112,14 @@ def test_compilation_output_structure(self, simple_circuit: object) -> None: assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" assert isinstance(hugr_bytes, bytes), "HUGR should be bytes" - # Check for HUGR markers - hugr_str = hugr_bytes.decode("utf-8") - is_hugr_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") or "{" in hugr_str[:100] - - assert is_hugr_envelope or is_json, "HUGR should be in envelope format or JSON" - - # If JSON, verify it can be parsed - if is_json or (is_hugr_envelope and "{" in hugr_str): - import json - - json_start = hugr_str.find("{") if is_hugr_envelope else 0 - if json_start != -1: - try: - json_data = json.loads(hugr_str[json_start:]) - assert isinstance( - json_data, - dict, - ), "HUGR JSON should be a dictionary" - assert len(json_data) > 0, "HUGR JSON should not be empty" - except json.JSONDecodeError as e: - pytest.fail(f"HUGR JSON is invalid: {e}") + # Binary HUGR Model envelope; load via Package to verify structure. + from hugr.package import Package + + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "HUGR should contain at least one module" + + total_nodes = sum(1 for module in pkg.modules for _ in module.nodes()) + assert total_nodes > 0, "HUGR should contain at least one node" class TestCompilationErrorHandling: diff --git a/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py b/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py index e34c3aa9a..41e9c43a1 100644 --- a/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py +++ b/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py @@ -25,7 +25,7 @@ def static_test() -> tuple[bool, bool, bool]: results = sim(Guppy(static_test)).qubits(5).quantum(state_vector()).run(10).to_dict() # Check we got results - format is [[m0, m1, m2], [m0, m1, m2], ...] - measurements = results.get("measurements", []) + measurements = results["measurements"] assert len(measurements) == 10, "Should have 10 measurements" for m in measurements: assert len(m) == 3, f"Each shot should have 3 measurements, got {len(m)}" @@ -51,7 +51,7 @@ def dynamic_loop_test() -> int: results = sim(Guppy(dynamic_loop_test)).qubits(10).quantum(state_vector()).seed(42).run(100) # Extract measurements - measurements = results.get("measurement_0", results.get("measurements", [])) + measurements = results.get("measurement_0", results["measurements"]) assert len(measurements) == 100, "Should have 100 measurements" # Due to Guppy limitation, only returns 0 or 1 (last measurement) @@ -66,14 +66,16 @@ def dynamic_loop_test() -> int: average = sum(measurements) / len(measurements) assert 0.3 < average < 0.7, f"Average should be around 0.5 (last measurement only), got {average}" - def test_dynamic_allocation_exceeds_limit(self) -> None: - """Test behavior when program requires more qubits than available. + def test_allocation_exceeds_limit_fixed_size_simulator(self) -> None: + """A fixed-size simulator must reject allocation past the qubit limit. - This test verifies how the system handles programs that need more - qubits than the specified limit. The behavior depends on whether - the compiler can optimize the program to fit within the limit. + Stabilizer-family simulators do not grow: a program that touches a + qubit index at or beyond the configured capacity must fail with the + capacity-guard error naming the op, the qubit, and the capacity -- + not succeed silently or die with an unrelated IPC failure. """ from guppylang.std.quantum import cx + from pecos_rslib import sparse_stab @guppy def four_qubit_program() -> tuple[bool, bool, bool, bool]: @@ -92,62 +94,41 @@ def four_qubit_program() -> tuple[bool, bool, bool, bool]: # Measure all return measure(q0), measure(q1), measure(q2), measure(q3) - # Try to run with only 3 qubits available (need 4) - # This tests the system's resource constraint handling - allocation_succeeded = False - error_was_expected = False - - try: - results = sim(Guppy(four_qubit_program)).qubits(3).quantum(state_vector()).run(10) - allocation_succeeded = True - - # If it succeeded, verify we got some results - # The compiler might have optimized the program - assert hasattr(results, "__getitem__"), "Results should be dict-like" - - # Check if we got any measurements - # Results dict should have measurement keys - has_measurements = "measurement_0" in results or "measurements" in results or "result" in results - - # If no measurement keys, check if results dict has any content - if not has_measurements and len(results) > 0: - has_measurements = True - - # The assertion is not critical - if the sim succeeded with 3 qubits - # for a 4-qubit program, it means optimization worked - # An empty results dict can happen if the simulation framework - # optimized away the measurements or hasn't returned them yet - if not has_measurements: - pass # Simulation succeeded, which is the main test - - except (RuntimeError, ValueError, OSError) as e: - error_was_expected = True - error_msg = str(e).lower() - - # Verify the error is related to resource constraints or IPC failure - # IPC failures often happen when subprocess terminates due to resource limits - expected_error_keywords = [ - "qubit", # Qubit allocation error - "range", # Index out of range - "sigpipe", # Process communication error - "subprocess", # Subprocess failure - "cannot send", # Communication failure - "resource", # Resource limit - "allocation", # Allocation failure - "exceeded", # Limit exceeded - "broken pipe", # IPC failure when subprocess terminates - "pipe", # General pipe errors - "ipc", # IPC errors - ] - - assert any( - keyword in error_msg for keyword in expected_error_keywords - ), f"Error should be related to resource constraints, got: {e}" - - # Either optimization succeeded or we got an expected error - assert ( - allocation_succeeded or error_was_expected - ), "Should either succeed with optimization or fail with resource error" + with pytest.raises(RuntimeError, match=r"targets qubit 3.*holds 3 qubits"): + sim(Guppy(four_qubit_program)).qubits(3).quantum(sparse_stab()).run(10) + + def test_allocation_exceeds_limit_state_vector_grows(self) -> None: + """The state-vector engine grows past the configured qubit count. + + Unlike the fixed-size simulators, the state-vector engine expands to + the highest qubit index a message touches, so a 4-qubit program with + .qubits(3) runs anyway -- and must still produce CORRECT physics + (a GHZ chain measures all-equal), not results computed on a + truncated register. + """ + from guppylang.std.quantum import cx + + @guppy + def four_qubit_ghz() -> tuple[bool, bool, bool, bool]: + q0 = qubit() + q1 = qubit() + q2 = qubit() + q3 = qubit() + + h(q0) + cx(q0, q1) + cx(q1, q2) + cx(q2, q3) + + return measure(q0), measure(q1), measure(q2), measure(q3) + + results = sim(Guppy(four_qubit_ghz)).qubits(3).quantum(state_vector()).seed(42).run(10).to_dict() + + measurements = results["measurements"] + assert len(measurements) == 10, "Should have 10 shots" + for m in measurements: + assert len(m) == 4, f"Each shot should measure 4 qubits, got {len(m)}" + assert len(set(m)) == 1, f"GHZ measurements must all agree, got {m}" @pytest.mark.skip( reason="Nested loops with int return not supported by HUGR interpreter", @@ -175,7 +156,7 @@ def nested_loop_test() -> int: # Need sufficient qubits for nested allocation results = sim(Guppy(nested_loop_test)).qubits(10).quantum(state_vector()).seed(42).run(50) - measurements = results.get("measurement_0", results.get("measurements", [])) + measurements = results.get("measurement_0", results["measurements"]) assert len(measurements) == 50, "Should have 50 measurements" # Count should be 0-6 (depends on measurements) @@ -200,7 +181,7 @@ def measurement_reuse_test() -> int: for max_qubits in [5, 10]: results = sim(measurement_reuse_test).qubits(max_qubits).quantum(state_vector()).seed(42).run(50) - measurements = results.get("measurement_0", results.get("measurements", [])) + measurements = results.get("measurement_0", results["measurements"]) assert len(measurements) == 50, f"Should have 50 measurements with max_qubits={max_qubits}" # Due to Guppy limitation, only returns 0 or 1 (last measurement) @@ -224,7 +205,7 @@ def single_qubit_test() -> bool: for max_q in [1, 5, 10, 20]: results = sim(single_qubit_test).qubits(max_q).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10, f"Should have 10 measurements with max_qubits={max_q}" @@ -264,7 +245,7 @@ def array_test() -> array[bool, 3]: # The result should be an array of 3 booleans for each shot # Results format is [[m0, m1, m2], [m0, m1, m2], ...] - measurements = results.get("measurements", []) + measurements = results["measurements"] assert len(measurements) == 50, "Should have 50 measurement sets" # Each measurement should be an array/tuple of 3 booleans diff --git a/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py b/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py index 6992c35fe..a3e1ec563 100644 --- a/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py +++ b/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py @@ -33,23 +33,14 @@ def prepare_bell_state() -> tuple[bool, bool]: # Use seed for reproducibility shot_vec = sim(Guppy(prepare_bell_state)).qubits(2).quantum(state_vector()).seed(42).run(1000) assert shot_vec is not None, "Should get results" - results = shot_vec.to_dict() - # Count outcomes - both_zero = 0 - both_one = 0 - anti_correlated = 0 - - # Results come as a dict with measurement keys - m1_list = results.get("measurement_0", []) - m2_list = results.get("measurement_1", []) - - for m1, m2 in zip(m1_list, m2_list, strict=False): - if m1 == 0 and m2 == 0: - both_zero += 1 - elif m1 == 1 and m2 == 1: - both_one += 1 - else: - anti_correlated += 1 + # "measurements" holds one row per shot in qubit-id order (each qubit is + # measured once here, so this matches the returned tuple's order). + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" + + both_zero = sum(1 for m1, m2 in shots if (m1, m2) == (0, 0)) + both_one = sum(1 for m1, m2 in shots if (m1, m2) == (1, 1)) + anti_correlated = len(shots) - both_zero - both_one # Bell state should only produce correlated outcomes assert anti_correlated == 0, f"Bell state should not produce anti-correlated outcomes, got {anti_correlated}" @@ -62,6 +53,34 @@ def prepare_bell_state() -> tuple[bool, bool]: assert 0.4 < both_one / total < 0.6, f"Should be ~50% |11⟩, got {both_one / total}" +def test_measurements_rows_are_qubit_id_ordered() -> None: + """Pin the raw-results contract: "measurements" rows are in QUBIT-ID order. + + HugrEngine::get_results assembles the rows from the per-qubit measurement + map sorted by qubit id, NOT from the guppy return-tuple order. A program + returning its measurements reversed must still yield qubit-id-ordered + rows; if tuple-order capture is ever implemented, this test documents the + intentional behavior change. + """ + + @guppy + def reversed_return() -> tuple[bool, bool]: + q1 = qubit() # qubit id 0 + q2 = qubit() # qubit id 1 + x(q1) # deterministically flip qubit 0 to |1> + m1 = measure(q1) + m2 = measure(q2) + return (m2, m1) # reversed relative to qubit-id order + + shot_vec = sim(Guppy(reversed_return)).qubits(2).quantum(state_vector()).seed(42).run(20) + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 20, "Should have one measurement row per shot" + + # Qubit-id order puts the X-flipped qubit 0 first even though the guppy + # function returns (m2, m1); tuple order would read (0, 1) instead. + assert all(tuple(row) == (1, 0) for row in shots), shots + + def test_ghz_state() -> None: """Test 3-qubit GHZ state preparation.""" @@ -87,24 +106,15 @@ def prepare_ghz_state() -> tuple[bool, bool, bool]: # Run simulation with state_vector backend shot_vec = sim(Guppy(prepare_ghz_state)).qubits(3).quantum(state_vector()).seed(42).run(1000) assert shot_vec is not None, "Should get results" - results = shot_vec.to_dict() + # "measurements" holds one row per shot in qubit-id order (each qubit is + # measured once here, so this matches the returned tuple's order). + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" # GHZ state should give either all 0s or all 1s - all_zero = 0 - all_one = 0 - other = 0 - - m1_list = results.get("measurement_0", []) - m2_list = results.get("measurement_1", []) - m3_list = results.get("measurement_2", []) - - for m1, m2, m3 in zip(m1_list, m2_list, m3_list, strict=False): - if m1 == 0 and m2 == 0 and m3 == 0: - all_zero += 1 - elif m1 == 1 and m2 == 1 and m3 == 1: - all_one += 1 - else: - other += 1 + all_zero = sum(1 for m1, m2, m3 in shots if (m1, m2, m3) == (0, 0, 0)) + all_one = sum(1 for m1, m2, m3 in shots if (m1, m2, m3) == (1, 1, 1)) + other = len(shots) - all_zero - all_one # GHZ state should only produce |000⟩ or |111⟩ assert other == 0, f"GHZ state should not produce mixed outcomes, got {other}" @@ -143,25 +153,18 @@ def phase_kickback_circuit() -> tuple[bool, bool]: return (m1, m2) # Run simulation with state_vector backend - results = sim(Guppy(phase_kickback_circuit)).qubits(2).quantum(state_vector()).seed(42).run(1000) - assert results is not None, "Should get results" + shot_vec = sim(Guppy(phase_kickback_circuit)).qubits(2).quantum(state_vector()).seed(42).run(1000) + assert shot_vec is not None, "Should get results" + # "measurements" holds one row per shot in qubit-id order (each qubit is + # measured once here, so this matches the returned tuple's order). + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" # The control qubit should measure |1⟩ in X basis (due to phase kickback) # The target should remain in |1⟩ - control_one_count = 0 - target_one_count = 0 - total = 0 - - if hasattr(results, "__getitem__"): - m1_list = results.get("measurement_0", []) - m2_list = results.get("measurement_1", []) - - for m1, m2 in zip(m1_list, m2_list, strict=False): - total += 1 - if m1 == 1: - control_one_count += 1 - if m2 == 1: - target_one_count += 1 + control_one_count = sum(1 for m1, _ in shots if m1 == 1) + target_one_count = sum(1 for _, m2 in shots if m2 == 1) + total = len(shots) # Control should be predominantly |1⟩ due to phase kickback assert ( @@ -192,19 +195,15 @@ def quantum_interferometer() -> bool: return measure(q) # Run simulation with state_vector backend - results = sim(Guppy(quantum_interferometer)).qubits(1).quantum(state_vector()).seed(42).run(1000) - assert results is not None, "Should get results" + shot_vec = sim(Guppy(quantum_interferometer)).qubits(1).quantum(state_vector()).seed(42).run(1000) + assert shot_vec is not None, "Should get results" + # "measurements" holds one single-element row per shot. + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" # Due to interference, should measure |1⟩ ~100% of the time - one_count = 0 - total = 0 - - if hasattr(results, "__getitem__"): - measurements = results.get("measurement_0", []) - for m in measurements: - total += 1 - if m == 1: - one_count += 1 + one_count = sum(1 for (m,) in shots if m == 1) + total = len(shots) assert one_count / total > 0.95, f"Should measure |1⟩ due to interference, got {one_count / total}" @@ -229,24 +228,19 @@ def rotation_circuit() -> bool: return measure(q) # Run simulation with state_vector backend - results = sim(Guppy(rotation_circuit)).qubits(1).quantum(state_vector()).seed(42).run(1000) + shot_vec = sim(Guppy(rotation_circuit)).qubits(1).quantum(state_vector()).seed(42).run(1000) - assert results is not None, "Should get results" + assert shot_vec is not None, "Should get results" + # "measurements" holds one single-element row per shot. + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" # After Ry(π/2), should be in equal superposition # Rz just adds phase, doesn't change measurement probabilities - zero_count = 0 - one_count = 0 - - if hasattr(results, "__getitem__"): - measurements = results.get("measurement_0", []) - for m in measurements: - if m == 0: - zero_count += 1 - else: - one_count += 1 - - total = zero_count + one_count + zero_count = sum(1 for (m,) in shots if m == 0) + one_count = len(shots) - zero_count + + total = len(shots) # Should be roughly 50/50 after Ry(π/2) assert 0.4 < zero_count / total < 0.6, f"Should be ~50% |0⟩ after Ry(π/2), got {zero_count / total}" assert 0.4 < one_count / total < 0.6, f"Should be ~50% |1⟩ after Ry(π/2), got {one_count / total}" diff --git a/python/quantum-pecos/tests/guppy/test_selene_build_process.py b/python/quantum-pecos/tests/guppy/test_selene_build_process.py index cd9b7e06b..752798ebc 100644 --- a/python/quantum-pecos/tests/guppy/test_selene_build_process.py +++ b/python/quantum-pecos/tests/guppy/test_selene_build_process.py @@ -4,7 +4,6 @@ HUGR from Guppy and create an executable that can be wrapped by SeleneExecutableEngine. """ -import json import tempfile import textwrap from pathlib import Path @@ -35,20 +34,10 @@ def simple_h() -> bool: assert hugr_bytes is not None, "HUGR compilation should succeed" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Parse HUGR to understand structure - hugr_str = hugr_bytes.decode("utf-8") - if hugr_str.startswith("HUGRiHJv"): - # Skip header and find JSON start - json_start = hugr_str.find("{", 9) - assert json_start != -1, "Should find JSON start in HUGR envelope" - hugr_str = hugr_str[json_start:] + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - # Validate JSON structure - try: - hugr_json = json.loads(hugr_str) - assert isinstance(hugr_json, dict), "HUGR should be valid JSON object" - except json.JSONDecodeError as e: - pytest.fail(f"HUGR should be valid JSON: {e}") + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "HUGR should load as a valid HUGR" with tempfile.TemporaryDirectory() as tmpdir: build_dir = Path(tmpdir) @@ -611,27 +600,13 @@ def simple_circuit() -> bool: return measure(q) hugr_bytes = compile_guppy_to_hugr(simple_circuit) - hugr_str = hugr_bytes.decode("utf-8") - - # Check format detection - is_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") - - assert is_envelope or is_json, "HUGR should be in envelope or JSON format" + assert hugr_bytes is not None, "Should produce HUGR bytes" + assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - if is_envelope: - # Verify envelope structure - assert len(hugr_str) > 9, "Envelope should have header and content" - json_start = hugr_str.find("{", 9) - assert json_start != -1, "Envelope should contain JSON" + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - # Extract and validate JSON - json_content = hugr_str[json_start:] - try: - parsed = json.loads(json_content) - assert isinstance(parsed, dict), "Should parse as JSON object" - except json.JSONDecodeError as e: - pytest.fail(f"Envelope JSON should be valid: {e}") + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "HUGR should load as a valid HUGR" def test_build_artifacts_structure(self) -> None: """Test the structure of build artifacts created.""" diff --git a/python/quantum-pecos/tests/guppy/test_selene_direct_integration.py b/python/quantum-pecos/tests/guppy/test_selene_direct_integration.py index d8d99bb87..618de0f5e 100644 --- a/python/quantum-pecos/tests/guppy/test_selene_direct_integration.py +++ b/python/quantum-pecos/tests/guppy/test_selene_direct_integration.py @@ -4,7 +4,6 @@ it with PECOS's ClassicalControlEngine infrastructure. """ -import json import tempfile from pathlib import Path from typing import Any @@ -296,35 +295,10 @@ def simple_h_gate() -> bool: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Try to understand HUGR format - hugr_str = hugr_bytes.decode("utf-8") + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - # Check if it's envelope format or JSON - is_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") - - assert is_envelope or is_json, "HUGR should be in envelope or JSON format" - - if is_json: - # Direct JSON format - try: - hugr_json = json.loads(hugr_str) - assert isinstance(hugr_json, dict), "HUGR JSON should be a dictionary" - assert len(hugr_json) > 0, "HUGR JSON should not be empty" - except json.JSONDecodeError as e: - pytest.fail(f"HUGR should be valid JSON: {e}") - - elif is_envelope: - # Envelope format - find JSON part - json_start = hugr_str.find("{", 9) - assert json_start != -1, "Envelope should contain JSON" - - json_part = hugr_str[json_start:] - try: - hugr_json = json.loads(json_part) - assert isinstance(hugr_json, dict), "HUGR JSON should be a dictionary" - except json.JSONDecodeError as e: - pytest.fail(f"Envelope JSON should be valid: {e}") + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "Should load as a valid HUGR" def test_multi_qubit_compilation(self) -> None: """Test compiling a multi-qubit program.""" @@ -342,15 +316,10 @@ def three_qubit_ghz() -> tuple[bool, bool, bool]: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 100, "Multi-qubit HUGR should be substantial" - # Verify it contains quantum operations - hugr_str = hugr_bytes.decode("utf-8") - - # Look for quantum operation indicators (might be in the JSON) - # These patterns might appear in operation names or types - quantum_indicators = ["quantum", "Quantum", "h", "cx", "measure"] + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - found_quantum = any(indicator in hugr_str for indicator in quantum_indicators) - assert found_quantum, "HUGR should contain quantum operation indicators" + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "Should load as a valid HUGR" def test_conditional_compilation(self) -> None: """Test compiling a program with conditional logic.""" @@ -369,13 +338,7 @@ def conditional_circuit() -> int: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Check that the HUGR represents control flow - hugr_str = hugr_bytes.decode("utf-8") - - # Control flow might appear as specific operation types - # Look for indicators of branching or conditionals + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - # At least check it's valid HUGR - assert "HUGRiHJv" in hugr_str or hugr_str.startswith( - "{", - ), "Should be valid HUGR format" + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "Should load as a valid HUGR" diff --git a/python/quantum-pecos/tests/guppy/test_selene_hugr_compilation.py b/python/quantum-pecos/tests/guppy/test_selene_hugr_compilation.py index 39713031f..1710cff53 100644 --- a/python/quantum-pecos/tests/guppy/test_selene_hugr_compilation.py +++ b/python/quantum-pecos/tests/guppy/test_selene_hugr_compilation.py @@ -1,14 +1,30 @@ -"""Test HUGR compilation through Selene (HUGR 0.13 compatible).""" - -import json +"""Test HUGR compilation through Selene.""" import pytest from guppylang.decorator import guppy as guppy_decorator from guppylang.std.quantum import cx, h, measure, qubit, x +from hugr.package import Package from pecos import Guppy, sim from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos_rslib import state_vector +# compile_guppy_to_hugr returns the BINARY HUGR envelope (Model format): the +# ASCII magic "HUGRiHJv", a format byte, then a compressed payload. It is not +# UTF-8 text, so these tests validate it by parsing with hugr's own reader. +HUGR_ENVELOPE_MAGIC = b"HUGRiHJv" + + +def _op_names(pkg: Package) -> set[str]: + """Collect the op names appearing in all modules of a HUGR package.""" + names = set() + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + op = module[n].op + op_def = getattr(op, "_op_def", None) + names.add(op_def.name if op_def is not None else type(op).__name__) + return names + @pytest.mark.optional_dependency class TestSeleneHUGRCompilation: @@ -71,33 +87,12 @@ def simple_circuit() -> bool: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Verify HUGR format - hugr_str = hugr_bytes.decode("utf-8") - - # Check if it's envelope format or direct JSON - is_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") - - assert is_envelope or is_json, "HUGR should be in valid format" + # Verify HUGR envelope format and parse it with hugr's own reader + assert hugr_bytes.startswith(HUGR_ENVELOPE_MAGIC), "HUGR should be in envelope format" - # Parse JSON content - if is_envelope: - json_start = hugr_str.find("{", 9) - assert json_start != -1, "Envelope should contain JSON" - json_content = hugr_str[json_start:] - else: - json_content = hugr_str - - try: - hugr_json = json.loads(json_content) - assert isinstance(hugr_json, dict), "HUGR should be valid JSON object" - - # Check for expected HUGR structure elements - # HUGR should have version info and graph structure - assert len(hugr_json) > 0, "HUGR JSON should not be empty" - - except json.JSONDecodeError as e: - pytest.fail(f"HUGR should contain valid JSON: {e}") + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "HUGR package should contain at least one module" + assert len(list(pkg.modules[0].nodes())) > 0, "HUGR module should have nodes" def test_complex_circuit_compilation(self) -> None: """Test compilation of more complex quantum circuits.""" @@ -135,14 +130,12 @@ def quantum_teleportation() -> tuple[bool, bool, bool]: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 100, "Complex circuit should produce substantial HUGR" - # Verify it contains quantum operations - hugr_str = hugr_bytes.decode("utf-8") - - # Look for quantum operation indicators - quantum_ops = ["quantum", "Quantum", "measure", "hadamard", "cnot"] - found_ops = [op for op in quantum_ops if op.lower() in hugr_str.lower()] + # Verify it contains the expected quantum operations + op_names = _op_names(Package.from_bytes(hugr_bytes)) - assert len(found_ops) > 0, "HUGR should contain quantum operation references" + assert "H" in op_names, f"HUGR should contain H ops, found {sorted(op_names)}" + assert "CX" in op_names, f"HUGR should contain CX ops, found {sorted(op_names)}" + assert any("Measure" in name for name in op_names), f"HUGR should contain measure ops, found {sorted(op_names)}" def test_parametric_circuit_compilation(self) -> None: """Test compilation of parametric quantum circuits.""" @@ -167,15 +160,10 @@ def parametric_circuit(n: int) -> int: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Check for loop/iteration structures in HUGR - hugr_str = hugr_bytes.decode("utf-8") - - # HUGR might represent loops as specific node types - - # At minimum, verify it's valid HUGR - assert "HUGRiHJv" in hugr_str or hugr_str.startswith( - "{", - ), "Should be valid HUGR format" + # Verify the parametric circuit still produces a loadable HUGR package + assert hugr_bytes.startswith(HUGR_ENVELOPE_MAGIC), "Should be valid HUGR envelope" + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "HUGR package should contain at least one module" @pytest.mark.optional_dependency @@ -245,10 +233,10 @@ def test_llvm_ir_patterns(self) -> None: @pytest.mark.optional_dependency class TestHUGRVersionCompatibility: - """Test HUGR version compatibility.""" + """Test HUGR envelope format compatibility.""" def test_hugr_version_detection(self) -> None: - """Test detection of HUGR version from compiled output.""" + """Test detection of the HUGR envelope format from compiled output.""" @guppy_decorator def version_test() -> bool: @@ -257,31 +245,17 @@ def version_test() -> bool: return measure(q) hugr_bytes = compile_guppy_to_hugr(version_test) - hugr_str = hugr_bytes.decode("utf-8") - - # Check for version indicators - if hugr_str.startswith("HUGRiHJv"): - # Envelope format - version in header - # Format: HUGRiHJv... - version_part = hugr_str[8:10] # Next chars might be version - assert len(version_part) > 0, "Should have version info in envelope" - elif hugr_str.startswith("{"): - # JSON format - might have version field - hugr_json = json.loads(hugr_str) - - # Look for version field in various places - if "version" in hugr_json: - hugr_json["version"] - elif "hugr_version" in hugr_json: - hugr_json["hugr_version"] - elif "metadata" in hugr_json and "version" in hugr_json["metadata"]: - hugr_json["metadata"]["version"] - - # Version might not always be present, but structure should be valid - assert isinstance(hugr_json, dict), "Should be valid JSON structure" - - def test_hugr_0_13_compatibility(self) -> None: - """Test compatibility with HUGR 0.13 format.""" + + # Envelope header: 8-byte magic followed by a format byte + assert hugr_bytes.startswith(HUGR_ENVELOPE_MAGIC), "Envelope should start with the HUGR magic" + assert len(hugr_bytes) > len(HUGR_ENVELOPE_MAGIC), "Envelope should have a format header after the magic" + + # The format must be one the hugr reader understands + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "Envelope should decode to a package with modules" + + def test_hugr_package_structure(self) -> None: + """Test that the compiled package has a well-formed module structure.""" @guppy_decorator def compatibility_test() -> tuple[bool, bool]: @@ -294,33 +268,15 @@ def compatibility_test() -> tuple[bool, bool]: hugr_bytes = compile_guppy_to_hugr(compatibility_test) assert hugr_bytes is not None, "Should produce HUGR bytes" - # HUGR 0.13 specific checks - hugr_str = hugr_bytes.decode("utf-8") - - # HUGR 0.13 uses specific node types and operation formats - # These might appear in the JSON structure - if "{" in hugr_str: - # Extract JSON part - json_start = hugr_str.find("{") - json_part = hugr_str[json_start:] + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "Package should contain at least one module" - try: - hugr_json = json.loads(json_part) - - # HUGR 0.13 should have nodes and edges structure - # The exact structure depends on the HUGR spec - assert isinstance(hugr_json, dict), "Should be valid HUGR structure" - - # Check for common HUGR elements - hugr_keys = list(hugr_json.keys()) - assert len(hugr_keys) > 0, "HUGR should have structure elements" - - except json.JSONDecodeError: - # Not JSON format, but still valid HUGR - pass + module = pkg.modules[0] + assert len(list(module.nodes())) > 0, "Module should have nodes" + assert module.entrypoint is not None, "Module should have an entrypoint" def test_hugr_metadata_preservation(self) -> None: - """Test that metadata is preserved through compilation.""" + """Test that the function name is preserved through compilation.""" @guppy_decorator def metadata_test() -> bool: @@ -329,12 +285,18 @@ def metadata_test() -> bool: h(q) return measure(q) - # Note: Guppy functions are frozen dataclasses, so we can't set attributes directly - # The metadata should come from the function definition itself - hugr_bytes = compile_guppy_to_hugr(metadata_test) - hugr_str = hugr_bytes.decode("utf-8") - - # Check if any metadata is preserved - # Function name should at least be preserved - assert "metadata_test" in hugr_str or len(hugr_bytes) > 50, "HUGR should preserve some function information" + pkg = Package.from_bytes(hugr_bytes) + + # The guppy function must survive as a named function definition + func_names = [] + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + f_name = getattr(module[n].op, "f_name", None) + if f_name: + func_names.append(f_name) + + assert any( + name.endswith("metadata_test") for name in func_names + ), f"HUGR should preserve the function name, found {func_names}" diff --git a/python/quantum-pecos/tests/guppy/test_semantic_sweep.py b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py new file mode 100644 index 000000000..61923c68e --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py @@ -0,0 +1,377 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dynamic semantic sweep: execute diverse guppy programs end-to-end. + +Every static review of the HUGR engine shared one blind spot: nobody RAN +adversarial programs against it. Each test here is a deterministic program +whose classical computation gates an X on a fresh qubit -- the measurement +is 1 iff the engine computed the exact expected value, so a wrong result, +a stalled loop, or a mis-propagated wire fails loudly. Programs are chosen +to cross-cut the semantics the engine implements: Euclidean division, +logical shifts, comparison chains, nested and zero-iteration loops, while +loops, measurement-conditioned branches, function calls, tuples, and +sequential loops sharing state. +""" + +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import h, measure, qubit, x +from pecos import Guppy, sim +from pecos_rslib import state_vector + + +def _expect_all_ones(prog, shots: int = 3) -> None: + results = sim(Guppy(prog)).qubits(4).quantum(state_vector()).seed(7).run(shots).to_dict() + raw_measurements = results["measurements"] + values = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert values == [1] * shots, f"semantic anchor failed: {values}" + + +def test_euclidean_matrix() -> None: + """Several signed division/modulo identities in one predicate.""" + + @guppy + def euclid_matrix() -> bool: + q = qubit() + ok = (-7) % 3 == 2 + ok = ok and (-7) // 3 == -3 + ok = ok and 7 % 3 == 1 + ok = ok and 7 // 3 == 2 + ok = ok and (-1) % 2 == 1 + ok = ok and (-9) // 2 == -5 + if ok: + x(q) + return measure(q) + + _expect_all_ones(euclid_matrix) + + +def test_shift_chain() -> None: + """Shift identities on positive values.""" + + @guppy + def shift_chain() -> bool: + q = qubit() + a = 1 + ok = (a << 10) == 1024 + ok = ok and (1024 >> 3) == 128 + ok = ok and (0 << 5) == 0 + ok = ok and (7 >> 3) == 0 + if ok: + x(q) + return measure(q) + + _expect_all_ones(shift_chain) + + +def test_comparison_chain_negatives() -> None: + """Signed ordering comparisons across zero.""" + + @guppy + def cmp_chain() -> bool: + q = qubit() + a = -5 + b = 3 + ok = a < b + ok = ok and a <= -5 + ok = ok and b > a + ok = ok and b >= 3 + ok = ok and a != b + ok = ok and (a + 8) == b + if ok: + x(q) + return measure(q) + + _expect_all_ones(cmp_chain) + + +def test_nested_loop_accumulation() -> None: + """A 3x4 nested loop must accumulate exactly 12.""" + + @guppy + def nested_accumulate() -> bool: + q = qubit() + count = 0 + for _i in range(3): + for _j in range(4): + count = count + 1 + if count == 12: + x(q) + return measure(q) + + _expect_all_ones(nested_accumulate) + + +def test_zero_iteration_inner_loop() -> None: + """A zero-range inner loop must not perturb the outer accumulation.""" + + @guppy + def zero_inner() -> bool: + q = qubit() + count = 0 + for _i in range(3): + count = count + 1 + for _j in range(0): + count = count + 100 + if count == 3: + x(q) + return measure(q) + + _expect_all_ones(zero_inner) + + +def test_while_countdown() -> None: + """A while loop must run its exact number of iterations.""" + + @guppy + def countdown() -> bool: + q = qubit() + n = 5 + steps = 0 + while n > 0: + n = n - 1 + steps = steps + 1 + if n == 0 and steps == 5: + x(q) + return measure(q) + + _expect_all_ones(countdown) + + +def test_measurement_correlated_branch() -> None: + """A measured bit routed through a branch must correlate exactly.""" + + @guppy + def correlated() -> bool: + q1 = qubit() + q2 = qubit() + h(q1) + m1 = measure(q1) + if m1: + x(q2) + m2 = measure(q2) + q3 = qubit() + if m1 == m2: + x(q3) + return measure(q3) + + _expect_all_ones(correlated, shots=10) + + +def test_function_call_arithmetic() -> None: + """A called function's return value must flow back exactly.""" + + @guppy + def double(n: int) -> int: + return n * 2 + + @guppy + def call_arith() -> bool: + q = qubit() + if double(21) == 42 and double(-3) == -6: + x(q) + return measure(q) + + _expect_all_ones(call_arith) + + +def test_tuple_roundtrip() -> None: + """Tuple construction and unpacking must preserve both values.""" + + @guppy + def tuple_roundtrip() -> bool: + q = qubit() + pair = (3, 4) + a, b = pair + if a + b == 7 and a * b == 12: + x(q) + return measure(q) + + _expect_all_ones(tuple_roundtrip) + + +def test_sequential_loops_shared_state() -> None: + """Two sequential loops over the same accumulator must both run.""" + + @guppy + def sequential_loops() -> bool: + q = qubit() + total = 0 + for i in range(4): + total = total + i + for _j in range(2): + total = total + 10 + if total == 26: + x(q) + return measure(q) + + _expect_all_ones(sequential_loops) + + +def test_branch_chain_on_loop_counter() -> None: + """An if/elif chain evaluated inside a loop must pick each arm.""" + + @guppy + def branch_chain() -> bool: + q = qubit() + low = 0 + mid = 0 + high = 0 + for i in range(6): + if i < 2: + low = low + 1 + elif i < 4: + mid = mid + 1 + else: + high = high + 1 + if low == 2 and mid == 2 and high == 2: + x(q) + return measure(q) + + _expect_all_ones(branch_chain) + + +def test_mixed_arithmetic_expression() -> None: + """Composite expressions with precedence and negatives.""" + + @guppy + def mixed_expr() -> bool: + q = qubit() + ok = (5 * 7 - 3) // 4 == 8 + ok = ok and (-13) % 5 == 2 + ok = ok and (2 + 3) * (7 - 4) == 15 + if ok: + x(q) + return measure(q) + + _expect_all_ones(mixed_expr) + + +def test_measured_bits_in_arithmetic() -> None: + """Measured booleans used as integers must arithmetic correctly.""" + + @guppy + def bits_arith() -> bool: + q1 = qubit() + q2 = qubit() + x(q1) # deterministic 1 + m1 = measure(q1) # 1 + m2 = measure(q2) # 0 + total = int(m1) + int(m1) + int(m2) + q3 = qubit() + if total == 2: + x(q3) + return measure(q3) + + _expect_all_ones(bits_arith) + + +def test_loop_carrying_measured_state() -> None: + """A loop accumulating deterministic measurement outcomes.""" + + @guppy + def loop_measures() -> bool: + count = 0 + for _i in range(3): + q = qubit() + x(q) + if measure(q): + count = count + 1 + q_out = qubit() + if count == 3: + x(q_out) + return measure(q_out) + + _expect_all_ones(loop_measures) + + +def test_result_label_containing_reserved_words() -> None: + """Labels are read from the op's typed String arg, so user labels + containing "result", "Op", or "Report" (which the old Debug-scrape + heuristics rejected) must survive verbatim as result keys. The array + variant matters most: its extra BoundedNat arg broke the old primary + pattern, falling through to the rejecting heuristics.""" + + @guppy + def labeled() -> None: + q = qubit() + x(q) + result("my_result_Report_Op", measure(q)) + + results = sim(Guppy(labeled)).qubits(2).quantum(state_vector()).seed(7).run(3).to_dict() + assert results["my_result_Report_Op"] == [1, 1, 1], f"keys: {sorted(results)}" + + +def test_array_result_label_containing_reserved_words() -> None: + """Array results carry [String, BoundedNat] type args; the label must + still come from the typed String arg.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array + + @guppy + def labeled_array() -> None: + qs = array(qubit() for _ in range(2)) + x(qs[0]) + x(qs[1]) + result("my_result_Report", measure_array(qs)) + + results = sim(Guppy(labeled_array)).qubits(3).quantum(state_vector()).seed(7).run(2).to_dict() + assert results["my_result_Report"] == [[1, 1], [1, 1]], f"keys: {sorted(results)}" + + +def test_negative_divisor_euclidean_semantics() -> None: + """Division/modulo with NEGATIVE divisors -- the regime where Python's + floor semantics and HUGR's Euclidean semantics diverge. HUGR idiv_s + takes an UNSIGNED divisor, so a negative divisor contributes its + two's-complement bit pattern (2^64 - 3 here): Euclidean q*m+r=n with + 0 <= r < m gives q=0, r=7. (Python would say 7 // -3 == -3.)""" + + @guppy + def neg_div() -> int: + a = 7 + b = -3 + return a // b + + @guppy + def neg_mod() -> int: + a = 7 + b = -3 + return a % b + + r = sim(Guppy(neg_div)).qubits(1).quantum(state_vector()).seed(1).run(2).to_dict() + assert list(r["return"]) == [0, 0], f"7 // -3 per HUGR spec: {r['return']}" + r = sim(Guppy(neg_mod)).qubits(1).quantum(state_vector()).seed(1).run(2).to_dict() + assert list(r["return"]) == [7, 7], f"7 %% -3 per HUGR spec: {r['return']}" + + +def test_logical_shift_on_negative_and_past_width() -> None: + """ishr is LOGICAL per the spec ("leftmost bits set to zero"), unlike + Python's arithmetic >>; and shifting by k >= width drops every bit.""" + + @guppy + def shift_negative() -> int: + a = -8 + return a >> 1 + + @guppy + def shift_past_width() -> int: + a = 5 + return a >> 70 + + r = sim(Guppy(shift_negative)).qubits(1).quantum(state_vector()).seed(1).run(2).to_dict() + # (-8 as u64) >> 1 = 0x7FFF_FFFF_FFFF_FFFC + assert list(r["return"]) == [0x7FFF_FFFF_FFFF_FFFC] * 2, f"logical shift: {r['return']}" + r = sim(Guppy(shift_past_width)).qubits(1).quantum(state_vector()).seed(1).run(2).to_dict() + assert list(r["return"]) == [0, 0], f"past-width shift: {r['return']}" diff --git a/python/quantum-pecos/tests/guppy/test_static_tuples.py b/python/quantum-pecos/tests/guppy/test_static_tuples.py index ceb789e44..7c103cbc1 100644 --- a/python/quantum-pecos/tests/guppy/test_static_tuples.py +++ b/python/quantum-pecos/tests/guppy/test_static_tuples.py @@ -91,7 +91,7 @@ def circuit_5_tuple() -> tuple[bool, bool, bool, bool, bool]: def test_1_tuple_return() -> None: """Test that 1-tuple (bool) returns work correctly.""" results = sim(Guppy(circuit_1_tuple)).qubits(1).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For single bool return, measurements is [[1], [1], ...] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 5 @@ -101,7 +101,7 @@ def test_1_tuple_return() -> None: def test_2_tuple_return() -> None: """Test that 2-tuple returns work correctly.""" results = sim(Guppy(circuit_2_tuple)).qubits(2).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For tuple return, measurements is [[1, 0], [1, 0], ...] # First qubit has X, second doesn't assert all(raw_measurements[i][0] == 1 for i in range(5)) @@ -111,7 +111,7 @@ def test_2_tuple_return() -> None: def test_3_tuple_return() -> None: """Test that 3-tuple returns work correctly.""" results = sim(Guppy(circuit_3_tuple)).qubits(3).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For tuple return, measurements is [[1, 0, 1], [1, 0, 1], ...] # Pattern: X, no X, X assert all(raw_measurements[i][0] == 1 for i in range(5)) @@ -122,7 +122,7 @@ def test_3_tuple_return() -> None: def test_4_tuple_return() -> None: """Test that 4-tuple returns work correctly.""" results = sim(Guppy(circuit_4_tuple)).qubits(4).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For tuple return, measurements is [[1, 0, 1, 0], [1, 0, 1, 0], ...] # Pattern: X, no X, X, no X assert all(raw_measurements[i][0] == 1 for i in range(5)) @@ -134,7 +134,7 @@ def test_4_tuple_return() -> None: def test_5_tuple_return() -> None: """Test that 5-tuple returns work correctly.""" results = sim(Guppy(circuit_5_tuple)).qubits(5).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For tuple return, measurements is [[1, 0, 1, 0, 1], [1, 0, 1, 0, 1], ...] # Pattern: X, no X, X, no X, X assert all(raw_measurements[i][0] == 1 for i in range(5)) diff --git a/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py new file mode 100644 index 000000000..a3b68082d --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py @@ -0,0 +1,74 @@ +"""Tests for constrained-ancilla surface-code Guppy generation.""" + +from __future__ import annotations + +import pytest + + +def test_surface_qubit_count_respects_ancilla_budget() -> None: + """The optional budget caps peak live ancillas without changing data qubits.""" + from pecos.guppy import get_num_qubits + + assert get_num_qubits(7) == 97 + assert get_num_qubits(9) == 161 + assert get_num_qubits(9, ancilla_budget=17) == 98 + assert get_num_qubits(9, ancilla_budget=999) == 161 + + +def test_surface_ancilla_budget_must_be_positive() -> None: + """Reject nonsensical budgets early.""" + from pecos.guppy import get_num_qubits + + with pytest.raises(ValueError, match="ancilla_budget must be >= 1"): + get_num_qubits(3, ancilla_budget=0) + + +def test_constrained_ancilla_surface_code_compiles_to_hugr() -> None: + """A budgeted surface memory experiment should still be valid Guppy/HUGR.""" + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy import make_surface_code + + program = make_surface_code(distance=3, num_rounds=1, basis="Z", ancilla_budget=2) + hugr = compile_guppy_to_hugr(program) + + assert len(hugr) > 0 + + +def test_constrained_ancilla_surface_code_traces_to_native_tick_circuit() -> None: + """Budgeted Guppy surface programs should work through traced-QIS DEM plumbing.""" + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + ) + + assert circuit.get_meta("ancilla_budget") == "2" + assert int(circuit.get_meta("num_measurements")) > 0 + + +def test_constrained_szz_surface_code_traces_to_native_tick_circuit() -> None: + """Budgeted SZZ/SZZdg surface programs should share the constrained Guppy path.""" + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + interaction_basis="szz", + ) + + gate_counts = circuit.gate_counts_by_type() + assert circuit.get_meta("ancilla_budget") == "2" + assert int(circuit.get_meta("num_measurements")) > 0 + assert gate_counts.get("RZZ", 0) > 0 + assert gate_counts.get("CX", 0) == 0 diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py new file mode 100644 index 000000000..335f5fff4 --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +import pytest + +pytest.importorskip("guppylang") + +from pecos.guppy.surface import ( + _guppy_module_cache_key, + generate_guppy_source, + generate_memory_experiment, +) +from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig +from pecos.qec.surface._twirl_sites import ( + pauli_active_gate_tag, + pauli_active_round_tag, + pauli_mask_gate_tag, + pauli_mask_round_tag, +) +from pecos.qec.surface.patch import SurfacePatch + +_SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubits2_hugr(" +_SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubits2_hugr(" + + +def _line_has_trace_metadata(line: str, key: str, value: str) -> bool: + return f'"{key}", "{value}"' in line or f'\\"{key}\\":\\"{value}\\"' in line + + +@pytest.fixture +def patch() -> SurfacePatch: + return SurfacePatch.create(distance=3) + + +def _assert_szz_prefix_barrier_host_order(src: str) -> None: + """Assert a real SZZ data-prefix pulse is fenced with its host qubits.""" + lines = src.splitlines() + for index, line in enumerate(lines): + if "phased_x(" not in line: + continue + prefix_meta = next( + i for i in range(index - 1, -1, -1) if _line_has_trace_metadata(lines[i], "source_kind", "szz_data_prefix") + ) + barrier_index = next(i for i in range(prefix_meta - 1, -1, -1) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) + host_meta = next( + i for i in range(index + 1, len(lines)) if _line_has_trace_metadata(lines[i], "source_kind", "szz_host") + ) + zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) + + assert barrier_index < prefix_meta < index < host_meta < zz_phase_index + return + + msg = "expected an SZZ touch with a hosted phased_x data-prefix pulse" + raise AssertionError(msg) + + +def test_no_twirl_source_has_no_rng_or_mask_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch) + + assert "RNG(" not in src + assert "random_int_bounded" not in src + assert "seeded_pcg32_with_quantum_entropy(" not in src + assert 'result("pauli_mask' not in src + assert "for _t in range(comptime(num_rounds)):" in src + assert 'result("final"' in src + + +def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz") + + assert "from guppylang.std.angles import angle" in src + assert "from guppylang.std.qsystem.functional import phased_x, rz, zz_phase" in src + assert "phased_x(" in src + assert "zz_phase(" in src + assert "angle(0.5)" in src + assert "angle(-0.5)" in src + assert "cx(" not in src + assert "h(az" in src + assert "vdg(d" in src + assert "v(d" in src + assert "sdg(d" in src + assert "s(d" in src + + +def test_szz_source_supports_constrained_ancilla_budget(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz", ancilla_budget=1) + + assert "zz_phase(" in src + assert "cx(" not in src + assert '"""Extract full syndrome in 8 ancilla-reuse batches (budget=1)."""' in src + assert "_a_b0_p0 = qubit()" in src + assert "_init_a_b0_p0 = qubit()" in src + + +def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="twirl integration is staged later"): + generate_guppy_source( + patch, + interaction_basis="szz", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=2, + ) + + +def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: + cx_key = _guppy_module_cache_key(patch, effective_budget=8) + szz_key = _guppy_module_cache_key(patch, effective_budget=8, interaction_basis="szz") + framed_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + barrier_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + szz_runtime_barriers=True, + ) + prefix_barrier_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) + + assert cx_key != szz_key + assert framed_key != szz_key + assert barrier_key != szz_key + assert prefix_barrier_key != barrier_key + assert "_ibcx" not in cx_key + assert "_ibszz" in szz_key + assert "_cfglobal_axis_cycle_f" in framed_key + assert "_szzrb-all" in barrier_key + assert "_szzrb-data-prefix" in prefix_barrier_key + + +def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz", num_rounds=2) + + assert "def syndrome_extraction_memory_r0" in src + assert "def syndrome_extraction_memory_r1" in src + assert "surf, syn = syndrome_extraction_memory_r0(surf)" in src + assert "surf, syn = syndrome_extraction_memory_r1(surf)" in src + assert "# Flush SZZ data frame before syndrome return" in src + assert "# Flush SZZ data frame before init_z_basis return" in src + assert "# Flush SZZ data frame before init_x_basis return" in src + assert 'result("final", final)' in src + + +def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers=True, + ) + + assert "from guppylang.std.builtins import array, owned, result" in src + assert _SZZ_RUNTIME_BARRIER_HELPER in src + assert _SZZ_RUNTIME_BARRIER_CALL not in generate_guppy_source( + patch, + interaction_basis="szz", + ) + + _assert_szz_prefix_barrier_host_order(src) + + +def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: SurfacePatch) -> None: + all_src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers="all", + ) + prefix_src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) + + assert _SZZ_RUNTIME_BARRIER_CALL in prefix_src + assert prefix_src.count(_SZZ_RUNTIME_BARRIER_CALL) < all_src.count(_SZZ_RUNTIME_BARRIER_CALL) + + _assert_szz_prefix_barrier_host_order(prefix_src) + + +def test_szz_runtime_barriers_reject_cx_source(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="interaction_basis='szz'"): + generate_guppy_source(patch, szz_runtime_barriers="data-prefix") + + +def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert "from guppylang.std.quantum import" in src + assert ", x, y, z" in src + assert "# Flush SZZ data frame before syndrome return" in src + assert "sdg(d0)\n vdg(d0)\n s(d0)" not in src + assert "def apply_logical_x" in src + assert "y(surf.d" in src + assert "def apply_logical_z" in src + assert "x(surf.d" in src + + +def test_szz_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=2, + basis="Z", + interaction_basis="szz", + ) + assert fn is not None + + +def test_szz_axis_cycle_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + assert fn is not None + + +def test_szz_runtime_barrier_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_runtime_barriers=True, + ) + assert fn is not None + + +def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=3, + ) + + assert "def _pcg32_next32(state: nat, inc: nat) -> tuple[nat, nat]:" in src + assert "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:" in src + assert "def seeded_pcg32_with_quantum_entropy(seed: int) -> tuple[nat, nat]:" in src + assert "entropy_q = qubit()" in src + assert "if measure(entropy_q):" in src + assert src.count("rng_state, rng_inc = seeded_pcg32_with_quantum_entropy(42)") == 2 + assert src.count('result("frame_mode:raw", True)') == 2 + assert "rng_state, active_draw_m_0_0 = _pcg32_next32(rng_state, rng_inc)" in src + assert "active_0_0 = active_draw_m_0_0 < nat(4294967296)" in src + assert "rng_state, m_draw_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "if active_0_0:" in src + assert " m_0_0 = m_draw_0_0" in src + assert "if m_0_0 == 1:" in src + assert " x(surf.data[0])" in src + assert "if m_0_0 == 2:" in src + assert " y(surf.data[0])" in src + assert "if m_0_0 == 3:" in src + assert " z(surf.data[0])" in src + + for r in range(2): + assert src.count(f'result("{pauli_mask_round_tag(r)}"') == 2 + assert f'result("{pauli_active_round_tag(r)}"' not in src + assert src.count("# === Round 2 (final, no twirl after) ===") == 2 + + +def test_scaled_twirl_source_emits_activation_tags_and_threshold(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(twirl_probability=0.5), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=2, + ) + + assert "active_0_0 = active_draw_m_0_0 < nat(2147483648)" in src + assert "rng_state, m_draw_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert f'result("{pauli_active_round_tag(0)}", array(active_0_0' in src + + +def test_canonical_frame_output_emits_raw_sibling_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=2, + ) + + assert 'result("frame_mode:canonical", True)' in src + assert "fx_0 = False" in src + assert "fz_0 = False" in src + assert "sx0_raw = measure(ax0)" in src + assert "sx0 = sx0_raw != sx0_flip" in src + assert 'result("raw:sx0:bit:0", sx0_raw)' in src + assert 'result("raw:final", final_raw)' in src + assert 'result("final", array(final_0' in src + + +def test_gate_local_twirl_source_emits_gate_tags_and_ancilla_frame_tracking( + patch: SurfacePatch, +) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate", frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=2, + ) + + assert 'result("frame_mode:canonical", True)' in src + assert f'result("{pauli_mask_gate_tag(0)}", array(' in src + assert "rng_state, active_draw_m_g0_o0 = _pcg32_next32(rng_state, rng_inc)" in src + assert "rng_state, m_draw_g0_o0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "x(ax" in src + assert "x(surf.data[" in src + assert "frame_x_ax" in src + assert "frame_z_az" in src + assert "raw:sx" in src + assert 'result("pauli_mask:round:' not in src + + +def test_scaled_gate_local_source_emits_activation_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate", twirl_probability=0.25), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=1, + ) + + assert "active_g0_o0 = active_draw_m_g0_o0 < nat(1073741824)" in src + assert f'result("{pauli_active_gate_tag(0)}", array(active_g0_o0, active_g0_o1))' in src + + +def test_twirl_validation_requires_rng_and_num_rounds(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="twirl and rng must be supplied together"): + generate_guppy_source(patch, twirl=TwirlConfig(), num_rounds=2) + with pytest.raises(ValueError, match="twirl and rng must be supplied together"): + generate_guppy_source(patch, rng=GuppyRngMaskConfig(seed=42)) + with pytest.raises(ValueError, match="num_rounds is required when twirl is supplied"): + generate_guppy_source( + patch, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + ) + + +def test_twirled_cache_key_includes_seed_rounds_and_frame_mode(patch: SurfacePatch) -> None: + raw = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) + raw_seed2 = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=2), + num_rounds=2, + ) + canonical = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) + round3 = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=3, + ) + gate_local = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) + scaled = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(twirl_probability=0.5), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) + + assert raw != _guppy_module_cache_key(patch, effective_budget=10) + assert raw != raw_seed2 + assert raw != canonical + assert raw != round3 + assert raw != gate_local + assert raw != scaled + assert "s1" in raw + assert "s2" in raw_seed2 + assert "p4294967296" in raw + assert "p2147483648" in scaled + assert "frame-raw" in raw + assert "frame-canonical" in canonical + assert "before_two_qubit_gate" in gate_local + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +@pytest.mark.parametrize("frame_output", ["raw", "canonical"]) +@pytest.mark.parametrize("site_schedule", ["between_rounds", "before_two_qubit_gate"]) +def test_twirled_memory_experiment_compiles( + patch: SurfacePatch, + basis: str, + frame_output: str, + site_schedule: str, +) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=2, + basis=basis, + twirl=TwirlConfig(site_schedule=site_schedule, frame_output=frame_output), + rng=GuppyRngMaskConfig(seed=7), + ) + assert fn is not None diff --git a/python/quantum-pecos/tests/guppy/test_variant_scoped.py b/python/quantum-pecos/tests/guppy/test_variant_scoped.py new file mode 100644 index 000000000..27502978e --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_variant_scoped.py @@ -0,0 +1,86 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for :func:`pecos.guppy.variant_scoped`. + +``variant_scoped`` gives a factory-local ``@guppy`` program a variant-unique +name so that building several parameterizations in one process does not collide +in guppylang's compile cache / Selene's build cache (which key on the program's +module-qualified / entry-point name). See ``variant.py`` for the mechanism. +""" + +import pytest +from guppylang import guppy +from guppylang.std.builtins import array, comptime, result +from guppylang.std.quantum import h, measure, qubit +from pecos.guppy import variant_scoped + + +def test_variant_scoped_suffixes_name_and_qualname() -> None: + """The variant values are appended to both ``__name__`` and ``__qualname__``.""" + + def prog() -> None: + pass + + base_qualname = prog.__qualname__ + returned = variant_scoped(prog, 3) + + assert returned is prog + assert prog.__name__ == "prog_3" + assert prog.__qualname__ == f"{base_qualname}_3" + + +def test_variant_scoped_multiple_values() -> None: + """Multiple distinguishing values are joined into the suffix.""" + + def prog() -> None: + pass + + variant_scoped(prog, 5, "Z") + assert prog.__name__ == "prog_5_Z" + + +def test_variant_scoped_sanitizes_unsafe_characters() -> None: + """Non-alphanumeric characters in a variant value are replaced, keeping the name valid.""" + + def prog() -> None: + pass + + variant_scoped(prog, "a-b.c") + assert prog.__name__ == "prog_a_b_c" + assert prog.__name__.replace("_", "").isalnum() + + +def test_variant_scoped_requires_a_value() -> None: + """Calling with no distinguishing value fails loud.""" + + def prog() -> None: + pass + + with pytest.raises(ValueError, match="at least one distinguishing value"): + variant_scoped(prog) + + +def test_variant_scoped_isolates_parameterizations() -> None: + """Two round counts built in one process compile to distinct HUGR. + + Without the variant suffix both parameterizations share the factory-local + ``@guppy`` name, so the second silently reuses the first's build; with it, + each compiles to its own program. + """ + from pecos.compilation_pipeline import compile_guppy_to_hugr + + def make(num_rounds: int) -> object: + def memory() -> None: + for _ in range(comptime(num_rounds)): + q = qubit() + result("synx", array(measure(q))) + anc = qubit() + h(anc) + _ = measure(anc) + + return guppy(variant_scoped(memory, num_rounds)) + + hugr_two = compile_guppy_to_hugr(make(2)) + hugr_five = compile_guppy_to_hugr(make(5)) + assert hugr_two != hugr_five diff --git a/python/quantum-pecos/tests/guppy/test_yz_gates.py b/python/quantum-pecos/tests/guppy/test_yz_gates.py index 817198a80..d7ff3ae28 100644 --- a/python/quantum-pecos/tests/guppy/test_yz_gates.py +++ b/python/quantum-pecos/tests/guppy/test_yz_gates.py @@ -17,7 +17,7 @@ def y_only() -> bool: results = sim(Guppy(y_only)).qubits(1).quantum(state_vector()).run(5).to_dict() # measurements is list of lists like [[1], [1], ...], extract last value from each shot - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert all(val == 1 for val in measurements) # Y|0⟩ should give |1⟩ @@ -33,7 +33,7 @@ def z_only() -> bool: results = sim(Guppy(z_only)).qubits(1).quantum(state_vector()).run(5).to_dict() # measurements is list of lists like [[0], [0], ...], extract last value from each shot - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert all(val == 0 for val in measurements) # Z|0⟩ should give |0⟩ @@ -55,7 +55,7 @@ def yz_tuple() -> tuple[bool, bool]: results = sim(Guppy(yz_tuple)).qubits(2).quantum(state_vector()).run(5).to_dict() # measurements is list of lists like [[1, 0], [1, 0], ...] for tuple returns - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] for i in range(5): assert raw_measurements[i][0] == 1 # Y|0⟩ should give |1⟩ @@ -83,7 +83,7 @@ def xyz_tuple() -> tuple[bool, bool, bool]: results = sim(Guppy(xyz_tuple)).qubits(3).quantum(state_vector()).run(5).to_dict() # measurements is list of lists like [[1, 1, 0], [1, 1, 0], ...] for tuple returns - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] for i in range(5): assert raw_measurements[i][0] == 1 # X|0⟩ should give |1⟩ diff --git a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py index 0d1f815a7..490e373fd 100644 --- a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py +++ b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py @@ -157,6 +157,16 @@ def test_manual_graph_construction(self) -> None: assert decoder.num_nodes >= 3 assert decoder.num_edges >= 4 + def test_from_dem_with_correlations(self) -> None: + """Test construction from DEM with decomposition correlations enabled.""" + from pecos_rslib.decoders import PyMatchingDecoder + + dem = "error(0.1) D0 D1 ^ D2 L0" + decoder = PyMatchingDecoder.from_dem_with_correlations(dem) + + result = decoder.decode([0, 0, 0]) + assert result.correction == [0] + class TestFusionBlossomDecoder: """Tests for FusionBlossomDecoder (mirrors fusion_blossom).""" diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py new file mode 100644 index 000000000..cd8f42fa2 --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import pytest +from pecos.qec.surface.decode import _validate_trace_hosted_operations_if_requested +from pecos.quantum.hosted import validate_hosted_operations + + +class FakeGateType: + def __init__(self, name: str) -> None: + self.name = name + + +class FakeGate: + def __init__( + self, + name: str, + qubits: list[int], + *, + meta: dict[str, object] | None = None, + ) -> None: + self.gate_type = FakeGateType(name) + self.qubits = qubits + self.meta = meta or {} + + +class FakeTick: + def __init__(self, gates: list[FakeGate]) -> None: + self._gates = gates + + def gate_batches(self) -> list[FakeGate]: + return self._gates + + +class FakeTickCircuit: + def __init__(self, ticks: list[list[FakeGate]]) -> None: + self._ticks = [FakeTick(gates) for gates in ticks] + + def num_ticks(self) -> int: + return len(self._ticks) + + def get_tick(self, tick_index: int) -> FakeTick: + return self._ticks[tick_index] + + def get_gate_meta(self, tick_index: int, gate_index: int, key: str) -> object: + return self._ticks[tick_index].gate_batches()[gate_index].meta[key] + + +def test_validate_hosted_operations_binds_local_to_later_host() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("Idle", [2])], + [FakeGate("SZZ", [2, 5], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit) + + assert len(bindings) == 1 + assert bindings[0].host_id == "host:a" + assert bindings[0].local_role == "basis_prefix" + assert bindings[0].local.gate_name == "H" + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].tick_separation == 2 + + +def test_validate_hosted_operations_preserves_source_provenance_metadata() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SXdg", + [2], + meta={ + "host_id": "host:a", + "local_role": "basis_prefix", + "source_kind": "szz_data_prefix", + "source_label": "host:a:prefix:1:SXDG", + "source_gate": "SXDG", + }, + ), + ], + [ + FakeGate( + "SZZ", + [2, 5], + meta={ + "host_id": "host:a", + "source_kind": "szz_host", + "source_label": "host:a", + "source_gate": "SZZ", + }, + ), + ], + ], + ) + + binding = validate_hosted_operations(circuit)[0] + + assert binding.local.metadata["source_kind"] == "szz_data_prefix" + assert binding.local.metadata["source_label"] == "host:a:prefix:1:SXDG" + assert binding.local.metadata["source_gate"] == "SXDG" + assert binding.host.metadata["source_kind"] == "szz_host" + assert binding.host.metadata["source_label"] == "host:a" + assert binding.host.metadata["source_gate"] == "SZZ" + + +def test_validate_hosted_operations_selects_later_shared_host_record() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SX", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("RZ", [9], meta={"host_id": "host:a"})], + [FakeGate("SZZ", [2, 5], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].host.qubits == (2, 5) + + +def test_validate_hosted_operations_can_require_unique_host_ids() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="host_id 'host:a' appears on 2 host gates"): + validate_hosted_operations(circuit, require_unique_host_id=True) + + +def test_validate_hosted_operations_unique_host_ids_allow_many_locals() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + FakeGate( + "S", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit, require_unique_host_id=True) + + assert len(bindings) == 2 + assert {binding.local.gate_name for binding in bindings} == {"H", "S"} + assert all(binding.host.gate_name == "SZZ" for binding in bindings) + + +def test_validate_hosted_operations_can_bind_without_shared_qubit_requirement() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SX", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("RZ", [9], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit, require_shared_qubit=False) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "RZ" + + +def test_validate_hosted_operations_rejects_ordering_drift_by_default() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="matching host metadata only before it"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_can_bind_prior_host_for_metadata_shape_audit() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + bindings = validate_hosted_operations(circuit, require_host_after_local=False) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].tick_separation == -1 + + +def test_validate_hosted_operations_rejects_missing_local_host_id() -> None: + circuit = FakeTickCircuit( + [[FakeGate("H", [0], meta={"local_role": "basis_prefix"})]], + ) + + with pytest.raises(ValueError, match="no non-empty 'host_id' metadata"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_rejects_unbound_local() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "missing", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "other"})], + ], + ) + + with pytest.raises(ValueError, match="has no host gate sharing a qubit"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_rejects_large_tick_separation() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("Idle", [0])], + [FakeGate("Idle", [0])], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="exceeding max_tick_separation=2"): + validate_hosted_operations(circuit, max_tick_separation=2) + + +def test_trace_hosted_validation_is_noop_unless_requested() -> None: + circuit = FakeTickCircuit( + [[FakeGate("H", [0], meta={"local_role": "basis_prefix"})]], + ) + + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=False, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + +def test_trace_hosted_validation_rejects_ordering_drift_when_requested() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="ordering drift"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=True, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + +def test_trace_hosted_validation_rejects_repeated_host_ids_when_requested() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="host_id 'host:a' appears on 2 host gates"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=True, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + +def test_trace_hosted_validation_can_check_separation_without_order_guard() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="exceeding max_tick_separation=0"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=False, + max_hosted_tick_separation=0, + context="test trace validation", + ) diff --git a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py index bb555b916..0973f9ff7 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py +++ b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py @@ -137,6 +137,27 @@ def test_selene_engine_python_exports() -> None: assert isinstance(named_builder, pecos.QisEngineBuilder) +def test_selene_engine_accepts_generic_runtime_plugin_shape() -> None: + """A downstream Selene runtime plugin object is sufficient; PECOS does not need to know its package.""" + from pathlib import Path + + import pecos + + class RuntimePlugin: + def __init__(self) -> None: + self.library_file = Path("libcustom_selene_runtime.so") + self.library_search_dirs = [Path("custom-selene-libs")] + + def get_init_args(self) -> list[str]: + return ["--hardware-profile=custom"] + + builder = pecos.qis_engine().selene_runtime(RuntimePlugin()) + assert isinstance(builder, pecos.QisEngineBuilder) + + engine_builder = pecos.selene_engine(RuntimePlugin()) + assert isinstance(engine_builder, pecos.QisEngineBuilder) + + def test_sim_guppy_can_use_selene_engine_via_qis_path() -> None: """Test that sim(Guppy(...)).classical(selene_engine()) routes HUGR through the QIS path.""" import pecos diff --git a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py index 1e7ec9cb4..894549469 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -25,6 +25,7 @@ from guppylang import guppy from guppylang.std.builtins import array, comptime, result from guppylang.std.quantum import cx, h, measure, measure_array, qubit, x +from pecos.guppy import variant_scoped @guppy @@ -42,20 +43,18 @@ def tagged_bits_named_array() -> None: def make_repeated_single_bit_results(num_rounds: int) -> object: """Create a tiny program that records the same named result repeatedly.""" - @guppy def repeated_single_bit_results() -> None: for _ in range(comptime(num_rounds)): q = qubit() bit = measure(q) result("synx", array(bit)) - return repeated_single_bit_results + return guppy(variant_scoped(repeated_single_bit_results, num_rounds)) def make_tiny_x_syndrome_memory(num_rounds: int) -> object: """Create a tiny memory-style circuit with fresh ancilla allocation each round.""" - @guppy def tiny_x_syndrome_memory() -> None: data = qubit() h(data) @@ -72,7 +71,7 @@ def tiny_x_syndrome_memory() -> None: final = measure_array(array(data)) result("final", final) - return tiny_x_syndrome_memory + return guppy(variant_scoped(tiny_x_syndrome_memory, num_rounds)) def make_tiny_x_syndrome_memory_raw(num_rounds: int) -> object: @@ -82,7 +81,6 @@ def make_tiny_x_syndrome_memory_raw(num_rounds: int) -> object: "named result collection is wrong". """ - @guppy def tiny_x_syndrome_memory_raw() -> None: data = qubit() h(data) @@ -97,7 +95,7 @@ def tiny_x_syndrome_memory_raw() -> None: h(data) _ = measure(data) - return tiny_x_syndrome_memory_raw + return guppy(variant_scoped(tiny_x_syndrome_memory_raw, num_rounds)) @guppy @@ -178,6 +176,30 @@ def test_capture_operation_trace_returns_in_memory_batches() -> None: assert trace[0]["lowered_quantum_ops"] +def test_capture_operation_trace_includes_named_result_provenance() -> None: + """Trace capture must preserve result(...) -> measurement-id provenance.""" + import pecos + from pecos.qec.surface.decode import named_result_traces_from_operation_trace + + _require_selene_runtime() + + trace = ( + pecos.sim(make_tiny_x_syndrome_memory(1)) + .classical(pecos.selene_engine()) + .quantum(pecos.stabilizer()) + .qubits(2) + .seed(123) + .capture_operation_trace() + ) + + named_traces = named_result_traces_from_operation_trace(trace) + names = {trace["name"] for trace in named_traces} + assert {"synx", "final"} <= names + assert any(chunk.get("stage") == "named_results" for chunk in trace) + for named_trace in named_traces: + assert len(named_trace["result_ids"]) == len(named_trace["values"]) + + def _collect_selene_named_results( instance: object, *, @@ -588,3 +610,39 @@ def test_surface_memory_noiseless_complementary_family_repeats_after_projection( ) for row in results[comp_key]: assert _round_blocks_repeat(row, num_rounds) + + +def test_divergent_classical_semantics_match_selene_reference() -> None: + """The HUGR engine's spec-derived pins for the Python-divergent regimes + (negative divisors under Euclidean division with an unsigned divisor + bit pattern; logical right shift on negative operands) must agree with + the Selene reference implementation. + + Values are compared as booleans because the local Selene runtime does + not link int-result reporting (print_int). Shift-past-width is pinned + engine-side only: hugr-core's ishr CONST-FOLDER panics (debug) or + mis-folds (release) on constant shifts >= width, so that program cannot + compile through the QIS path until the upstream fix lands. + """ + import pecos + from guppylang import guppy + from guppylang.std.builtins import result + + _require_selene_runtime() + _configure_selene_caches() + + @guppy + def divergent_agrees() -> None: + a = 7 + b = -3 + result("div", (a // b) == 0) + result("mod", (a % b) == 7) + c = -8 + result("shr", (c >> 1) == 9223372036854775804) + + r = pecos.sim(pecos.Guppy(divergent_agrees)).qubits(1).classical(pecos.selene_engine()).seed(1).run(1).to_dict() + assert [int(r["div"][0]), int(r["mod"][0]), int(r["shr"][0])] == [ + 1, + 1, + 1, + ], f"Selene disagrees with the engine's spec pins: {r}" diff --git a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py new file mode 100644 index 000000000..23954c2af --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py @@ -0,0 +1,171 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Contract tests for routing Python sim() to the pecos-neo stack. + +Mirrors the Rust contract tests in crates/pecos/tests/neo_routing_test.rs: +the neo stack must return the same results contract as the engines stack, +with exact equality for deterministic programs and statistical agreement +under noise. +""" + +from __future__ import annotations + +import pytest +from pecos_rslib import Qasm, depolarizing_noise, sim, state_vector + +DETERMINISTIC_CONDITIONAL = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[2]; +creg c[2]; +x q[0]; +measure q[0] -> c[0]; +if (c == 1) x q[1]; +measure q[1] -> c[1]; +""" + +X_MEASURE = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[1]; +creg c[1]; +x q[0]; +measure q[0] -> c[0]; +""" + + +def test_neo_stack_matches_engines_for_deterministic_qasm() -> None: + engines = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).seed(42).run(5) + neo = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack("neo").seed(42).run(5) + + assert list(engines["c"]) == list(neo["c"]) + assert all(value == 3 for value in neo["c"]) # c0 = c1 = 1 + + +def test_neo_stack_parallel_matches_engines() -> None: + engines = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).seed(7).workers(2).run(6) + neo = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack("neo").seed(7).workers(2).run(6) + + assert list(engines["c"]) == list(neo["c"]) + + +def test_neo_stack_measurement_noise_rate_matches_engines() -> None: + """Measurement-only noise: P(c = 0) = p_meas on both stacks.""" + p_meas = 0.2 + shots = 4000 + + def rate_of_zero(stack: str) -> float: + noise = ( + depolarizing_noise() + .with_prep_probability(0.0) + .with_meas_probability(p_meas) + .with_p1_probability(0.0) + .with_p2_probability(0.0) + ) + builder = sim(Qasm.from_string(X_MEASURE)).noise(noise).seed(42) + if stack == "neo": + builder = builder.stack("neo") + results = builder.run(shots) + zeros = sum(1 for value in results["c"] if value == 0) + return zeros / shots + + engines_rate = rate_of_zero("engines") + neo_rate = rate_of_zero("neo") + + # ~5 sigma for p = 0.2 at 4000 shots is ~0.032. + assert abs(engines_rate - p_meas) < 0.035 + assert abs(neo_rate - p_meas) < 0.035 + + +def test_explicit_engines_stack_is_the_default_path() -> None: + default = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).seed(3).run(4) + explicit = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack("engines").seed(3).run(4) + + assert list(default["c"]) == list(explicit["c"]) + + +def test_unknown_stack_is_rejected() -> None: + with pytest.raises(ValueError, match="Unknown simulation stack"): + sim(Qasm.from_string(X_MEASURE)).stack("warp-drive") + + +def test_neo_stack_rejects_explicit_quantum_backend() -> None: + with pytest.raises(RuntimeError, match="not yet routed to the neo stack"): + sim(Qasm.from_string(X_MEASURE)).stack("neo").quantum(state_vector()).run(5) + + +def test_neo_stack_rejects_build() -> None: + with pytest.raises(RuntimeError, match="build"): + sim(Qasm.from_string(X_MEASURE)).stack("neo").build() + + +def test_missing_qasm_source_reports_the_real_problem() -> None: + """A builder with no program must say so, not misreport an unrouted + .classical() configuration (regression: review finding S2).""" + from pecos_rslib import qasm_engine + + for stack in ["engines", "neo"]: + with pytest.raises(RuntimeError, match="No QASM source specified"): + qasm_engine().to_sim().stack(stack).run(2) + + +def test_neo_stack_rejects_explicit_classical_engine() -> None: + """An explicit .classical() engine must be refused on neo rather than + silently dropped (regression: review finding S9). The engines stack + still accepts it.""" + from pecos_rslib import qasm_engine + + explicit = qasm_engine().program(Qasm.from_string(X_MEASURE)) + + with pytest.raises(RuntimeError, match="not routed to the neo stack"): + sim(Qasm.from_string(X_MEASURE)).classical(explicit).stack("neo").run(5) + + # Same configuration is fine on the engines stack. + results = sim(Qasm.from_string(X_MEASURE)).classical(explicit).stack("engines").run(5) + assert len(list(results["c"])) == 5 + + +def test_missing_source_wins_over_classical_override_on_neo() -> None: + """A sourceless .classical() builder must report the missing source, + not the neo classical-override rejection, since the missing source is + the more fundamental error (re-review S2/S9 ordering gap).""" + from pecos_rslib import qasm_engine + + for stack in ["engines", "neo"]: + with pytest.raises(RuntimeError, match="No QASM source specified"): + qasm_engine().to_sim().classical(qasm_engine()).stack(stack).run(1) + + +# --- Unified .shots(n) / argless .run() (mirrors the Rust facade) ---------- + + +@pytest.mark.parametrize("stack", ["engines", "neo"]) +def test_shots_builder_matches_run_argument(stack: str) -> None: + """`.shots(n).run()` must equal `.run(n)` on both stacks: shots is a + builder concern, and the argless run is the unified spelling.""" + via_shots = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack(stack).seed(42).shots(5).run() + via_arg = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack(stack).seed(42).run(5) + assert list(via_shots["c"]) == list(via_arg["c"]) + assert len(list(via_shots["c"])) == 5 + + +def test_run_argument_overrides_shots_builder() -> None: + """A `run(shots)` argument wins over a prior `.shots(n)`.""" + results = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).seed(42).shots(99).run(5) + assert len(list(results["c"])) == 5 + + +def test_run_without_shots_fails_fast() -> None: + """Neither `.shots(n)` nor a `run()` argument -> a loud error, never a + silent default.""" + with pytest.raises(ValueError, match="No shot count configured"): + sim(Qasm.from_string(X_MEASURE)).seed(42).run() diff --git a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py new file mode 100644 index 000000000..e20e8fdda --- /dev/null +++ b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py @@ -0,0 +1,137 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Unit tests for examples/surface/compare_surface_sweep_json.py.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from types import ModuleType + + +def _repo_root() -> Path: + cur = Path(__file__).resolve() + for candidate in [cur, *cur.parents]: + if (candidate / "Justfile").is_file() and (candidate / "examples").is_dir(): + return candidate + msg = f"Could not locate repo root above {cur}" + raise RuntimeError(msg) + + +_COMPARE_MODULE_NAME = "_surface_sweep_compare_under_test" + + +def _load_compare_module() -> ModuleType: + example_path = _repo_root() / "examples" / "surface" / "compare_surface_sweep_json.py" + spec = importlib.util.spec_from_file_location(_COMPARE_MODULE_NAME, example_path) + if spec is None or spec.loader is None: + msg = f"Could not load comparison module from {example_path}" + raise RuntimeError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[_COMPARE_MODULE_NAME] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(_COMPARE_MODULE_NAME, None) + raise + return module + + +@pytest.fixture(scope="module") +def compare() -> ModuleType: + return _load_compare_module() + + +def _point(*, distance: int, p: float, errors: int, shots: int = 1000) -> dict[str, object]: + return { + "backend": "native_sampler", + "basis": "Z", + "distance": distance, + "physical_error_rate": p, + "total_rounds": distance, + "num_logical_errors": errors, + "num_shots": shots, + } + + +def _write_sweep(path: Path, points: list[dict[str, object]]) -> None: + path.write_text(json.dumps({"points": points}), encoding="utf-8") + + +def test_report_defaults_to_jeffreys_and_omits_cross_distance_pooled( + compare: ModuleType, + tmp_path: Path, +) -> None: + left = tmp_path / "left.json" + right = tmp_path / "right.json" + _write_sweep(left, [_point(distance=3, p=0.004, errors=5), _point(distance=5, p=0.004, errors=2)]) + _write_sweep(right, [_point(distance=3, p=0.004, errors=6), _point(distance=5, p=0.004, errors=1)]) + + report = compare.build_report( + left, + right, + left_label="CX", + right_label="SZZ", + include_ci=True, + interval_method="jeffreys", + include_cross_distance_pooled=False, + ) + + assert "- intervals: jeffreys 95%" in report + assert "- z-scores: descriptive unpooled Wald z-scores" in report + assert "## Aggregate Over Physical Error Rates" in report + assert "## Pooled Across Distances" not in report + + +def test_cross_distance_pooled_table_is_explicitly_labeled( + compare: ModuleType, + tmp_path: Path, +) -> None: + left = tmp_path / "left.json" + right = tmp_path / "right.json" + _write_sweep(left, [_point(distance=3, p=0.004, errors=5), _point(distance=5, p=0.004, errors=2)]) + _write_sweep(right, [_point(distance=3, p=0.004, errors=6), _point(distance=5, p=0.004, errors=1)]) + + report = compare.build_report( + left, + right, + left_label="CX", + right_label="SZZ", + include_ci=True, + interval_method="jeffreys", + include_cross_distance_pooled=True, + ) + + assert "## Pooled Across Distances By Backend And Basis" in report + assert "not a scaling or threshold statement" in report + + +def test_duplicate_point_keys_raise(compare: ModuleType, tmp_path: Path) -> None: + duplicate = tmp_path / "duplicate.json" + point = _point(distance=3, p=0.004, errors=5) + _write_sweep(duplicate, [point, point]) + + with pytest.raises(ValueError, match="duplicate point key"): + compare.load_points(duplicate) + + +def test_wilson_interval_remains_available(compare: ModuleType) -> None: + low, high = compare.binomial_interval(0, 100, "wilson") + assert low == pytest.approx(0.0, abs=1e-15) + assert 0.0 < high < 0.1 diff --git a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py index 7f01a1e00..1c4f80788 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py +++ b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py @@ -195,6 +195,21 @@ def test_linear_regression_requires_matching_lengths(sweep: ModuleType) -> None: sweep._linear_regression([1.0, 2.0], [3.0]) +def test_native_sampler_model_tracks_decoder_dem_requirements(sweep: ModuleType) -> None: + """Raw-DEM decoders should sample the exact native influence model.""" + assert sweep._native_sampler_model_for_decoder("pymatching") == "dem" + assert sweep._native_sampler_model_for_decoder("pymatching_correlated") == "dem" + assert sweep._native_sampler_model_for_decoder("tesseract") == "influence_dem" + assert sweep._native_sampler_model_for_decoder("bp_osd") == "influence_dem" + + +def test_create_dem_decoder_supports_correlated_pymatching(sweep: ModuleType) -> None: + """The explicit correlated PyMatching option should construct a decoder.""" + decoder = sweep._create_dem_decoder("pymatching_correlated", "error(0.1) D0 ^ D1") + + assert "PyMatchingDecoder" in repr(decoder) + + # --------------------------------------------------------------------------- # Per-round rate fit # --------------------------------------------------------------------------- diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index f6bdc67e2..aa69079cc 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -19,8 +19,10 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface._ancilla_batching import ( + BALANCED_DATA_ANCILLA_SCHEDULE, batched_stabilizers, normalize_ancilla_budget, + normalize_ancilla_schedule, ) # --- normalize_ancilla_budget ----------------------------------------------- @@ -62,6 +64,21 @@ def test_normalize_ancilla_budget_rejects_non_int() -> None: normalize_ancilla_budget(8, "1") +# --- normalize_ancilla_schedule --------------------------------------------- + + +def test_normalize_ancilla_schedule_accepts_named_policies() -> None: + assert normalize_ancilla_schedule(None) == "default" + assert normalize_ancilla_schedule("default") == "default" + assert normalize_ancilla_schedule("balanced_data_v1") == BALANCED_DATA_ANCILLA_SCHEDULE + assert normalize_ancilla_schedule("balanced-data-v1") == BALANCED_DATA_ANCILLA_SCHEDULE + + +def test_normalize_ancilla_schedule_rejects_unknown_policy() -> None: + with pytest.raises(ValueError, match=r"ancilla_schedule must be one of"): + normalize_ancilla_schedule("row-scan") + + # --- batched_stabilizers (concrete sequences) ------------------------------- @@ -159,6 +176,53 @@ def test_batched_stabilizers_clamps_oversized_budget() -> None: assert len(huge[0]) == total +def test_balanced_data_schedule_is_explicit_and_deterministic() -> None: + """The balanced schedule is a named non-default policy, not a change to + the legacy batching semantics.""" + patch = SurfacePatch.create(distance=3) + + assert batched_stabilizers(patch, 2) == [ + [("X", 0), ("Z", 0)], + [("X", 1), ("Z", 1)], + [("X", 2), ("Z", 2)], + [("X", 3), ("Z", 3)], + ] + assert batched_stabilizers( + patch, + 2, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) == [ + [("X", 0), ("Z", 3)], + [("X", 3), ("Z", 0)], + [("Z", 1), ("Z", 2)], + [("X", 2), ("X", 1)], + ] + + +def test_balanced_data_schedule_spreads_d9_a17_batches() -> None: + """The d=9/a17 target gets equal-size batches and no data qubit whose + four adjacent checks all live in one batch.""" + patch = SurfacePatch.create(distance=9) + batches = batched_stabilizers( + patch, + 17, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) + + assert [len(batch) for batch in batches] == [16, 16, 16, 16, 16] + + batch_of = {stabilizer: batch_idx for batch_idx, batch in enumerate(batches) for stabilizer in batch} + touches_by_data: dict[int, set[int]] = {} + for stab in patch.geometry.x_stabilizers: + for data_qubit in stab.data_qubits: + touches_by_data.setdefault(data_qubit, set()).add(batch_of[("X", stab.index)]) + for stab in patch.geometry.z_stabilizers: + for data_qubit in stab.data_qubits: + touches_by_data.setdefault(data_qubit, set()).add(batch_of[("Z", stab.index)]) + + assert min(len(batch_indices) for batch_indices in touches_by_data.values()) > 1 + + # --- D1: pin emitted CX sequences for the constrained Guppy codegen -------- # The byte-identical traced-vs-traced DEM oracle and the lowered-qubit-stream # invariant catch many constrained-codegen errors, but not a wrong-CX-order / diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py new file mode 100644 index 000000000..e3a85055a --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -0,0 +1,577 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Surface check-plan metadata tests.""" + +from __future__ import annotations + +import ast +import hashlib +import json +from dataclasses import replace + +import pytest + +_PACKED_TRACE_METADATA_JSON_KEY = "__pecos_trace_metadata_json_v1__" + + +def _packed_trace_metadata_records(source: str) -> list[tuple[int, dict[str, str]]]: + records: list[tuple[int, dict[str, str]]] = [] + sentinel = f"{json.dumps(_PACKED_TRACE_METADATA_JSON_KEY)}, " + for line_index, line in enumerate(source.splitlines()): + if "pecos_qis_trace_metadata_qubit_hugr(" not in line or sentinel not in line: + continue + packed_literal = line.split(sentinel, 1)[1].rsplit(")", 1)[0] + records.append((line_index, json.loads(ast.literal_eval(packed_literal)))) + return records + + +def _first_metadata_line( + source: str, + *, + start_line: int = 0, + **expected: str, +) -> tuple[int, dict[str, str]]: + for line_index, metadata in _packed_trace_metadata_records(source): + if line_index < start_line: + continue + if all(metadata.get(key) == value for key, value in expected.items()): + return line_index, metadata + msg = f"could not find packed trace metadata after line {start_line}: {expected!r}" + raise AssertionError(msg) + + +def _has_metadata_prefix(source: str, key: str, prefix: str) -> bool: + return any( + isinstance(metadata.get(key), str) and metadata[key].startswith(prefix) + for _, metadata in _packed_trace_metadata_records(source) + ) + + +def test_check_plan_default_resolves_to_cx_metadata() -> None: + from pecos.qec.surface._check_plan import ( + canonical_check_plan_json, + default_surface_check_plan_id, + resolve_surface_check_plan, + surface_check_plan_ids, + ) + + plan = resolve_surface_check_plan() + + assert surface_check_plan_ids() == ( + "cx_balanced_data_v1", + "cx_standard_v1", + "szz_balanced_data_v1", + "szz_boundary_first_balanced_data_v1", + "szz_boundary_first_v1", + "szz_current_v1", + ) + assert default_surface_check_plan_id() == "cx_standard_v1" + assert default_surface_check_plan_id("SZZ") == "szz_current_v1" + assert plan.plan_id == "cx_standard_v1" + assert plan.interaction_basis == "cx" + assert plan.synthesis_identity == { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } + assert plan.resolved_metadata["metadata_version"] == 1 + assert plan.resolved_metadata["hash_algorithm"] == "sha256" + assert plan.resolved_metadata["hash_serialization"] == "canonical-json-v1" + assert "metadata_version" not in plan.semantic_content + assert ( + plan.resolved_hash + == hashlib.sha256( + canonical_check_plan_json(plan.semantic_content).encode("utf-8"), + ).hexdigest() + ) + + +def test_check_plan_is_source_of_truth_for_basis() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_current_v1") + + assert plan.plan_id == "szz_current_v1" + assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } + + +def test_boundary_first_szz_check_plan_resolves_to_concrete_synthesis() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_boundary_first_v1") + + assert plan.plan_id == "szz_boundary_first_v1" + assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } + assert plan.semantic_content["x_check"]["sign_policy"] == "boundary_first_szz_sign_vector_v1" + assert plan.semantic_content["z_check"]["sign_policy"] == "boundary_first_szz_sign_vector_v1" + + +@pytest.mark.parametrize( + ("plan_id", "interaction_basis", "phase_pattern"), + [ + ("cx_balanced_data_v1", "cx", "none"), + ("szz_balanced_data_v1", "szz", "standard"), + ("szz_boundary_first_balanced_data_v1", "szz", "boundary-first"), + ], +) +def test_balanced_data_check_plans_resolve_to_explicit_schedule( + plan_id: str, + interaction_basis: str, + phase_pattern: str, +) -> None: + from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + resolve_surface_check_plan, + ) + + plan = resolve_surface_check_plan(check_plan=plan_id) + + assert plan.interaction_basis == interaction_basis + assert plan.synthesis_identity == { + "family": interaction_basis, + "szz_phase_pattern": phase_pattern, + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + } + assert plan.semantic_content["schedule"]["ancilla_batch_policy"] == "balanced-data-v1" + assert ancilla_schedule_for_check_plan(plan) == "balanced-data-v1" + require_current_surface_check_plan_renderer(plan, context="unit-test") + + +def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: + from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_current_v1") + unsupported = dict(plan.semantic_content) + unsupported["synthesis_identity"] = { + **dict(plan.synthesis_identity), + "szz_phase_pattern": "checkerboard", + } + unsupported_plan = replace( + plan, + synthesis_identity=dict(unsupported["synthesis_identity"]), + semantic_content=unsupported, + ) + + with pytest.raises(NotImplementedError, match=r"unit-test.*checkerboard"): + require_current_surface_check_plan_renderer( + unsupported_plan, + context="unit-test", + ) + + +def test_check_plan_and_interaction_basis_mismatch_fails_loudly() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + with pytest.raises(ValueError, match="conflicts with check_plan"): + resolve_surface_check_plan( + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_guppy_surface_code_module_records_resolved_check_plan() -> None: + from pecos.guppy import get_surface_code_module + + module = get_surface_code_module(3, check_plan="szz_current_v1") + + assert module["interaction_basis"] == "szz" + assert module["check_plan"] == "szz_current_v1" + assert module["resolved_check_plan"]["semantic_content"]["interaction_basis"] == "szz" + assert len(module["resolved_check_plan_hash"]) == 64 + + +def test_guppy_surface_code_rejects_plan_basis_mismatch() -> None: + from pecos.guppy import make_surface_code + + with pytest.raises(ValueError, match="conflicts with check_plan"): + make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_guppy_surface_code_accepts_check_plan_as_source_of_truth() -> None: + from pecos.guppy import make_surface_code + + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + check_plan="szz_current_v1", + ) + + assert program is not None + + +@pytest.mark.parametrize( + "check_plan", + [ + "cx_balanced_data_v1", + "szz_balanced_data_v1", + "szz_boundary_first_balanced_data_v1", + ], +) +def test_guppy_surface_code_accepts_balanced_data_check_plans(check_plan: str) -> None: + from pecos.guppy import make_surface_code + + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + ancilla_budget=2, + check_plan=check_plan, + ) + + assert program is not None + + +def test_surface_code_memory_records_resolved_check_plan() -> None: + from pecos.qec.surface import surface_code_memory + + result = surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=4, + rounds=1, + seed=123, + check_plan="szz_current_v1", + ) + + assert result.interaction_basis == "szz" + assert result.check_plan == "szz_current_v1" + assert result.resolved_check_plan is not None + assert result.resolved_check_plan["semantic_content"]["interaction_basis"] == "szz" + assert len(result.resolved_check_plan_hash) == 64 + + +def test_surface_code_memory_rejects_plan_basis_mismatch() -> None: + from pecos.qec.surface import surface_code_memory + + with pytest.raises(ValueError, match="conflicts with check_plan"): + surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=0, + rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_check_plan_does_not_change_current_szz_dem() -> None: + from pecos.qec.surface import NoiseModel, SurfacePatch + from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.001, p_meas=0.001, p_prep=0.001) + + by_basis = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + interaction_basis="szz", + ) + by_plan = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + check_plan="szz_current_v1", + ) + + assert by_plan == by_basis + + +def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import ( + generate_dag_circuit_from_patch, + generate_guppy_from_patch, + generate_stim_from_patch, + generate_tick_circuit_from_patch, + ) + from pecos.qec.surface.decode import build_memory_circuit + + patch = SurfacePatch.create(distance=3) + + stim_text = generate_stim_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + add_detectors=False, + ) + assert "CX" not in stim_text + assert "SQRT_ZZ" in stim_text + + dag_circuit = generate_dag_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + assert "SZZ" in {dag_circuit.gate(node).gate_type.name for node in dag_circuit.nodes()} + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + memory_circuit = build_memory_circuit( + patch=patch, + rounds=1, + check_plan="szz_current_v1", + ) + assert int(memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) + + guppy_source = generate_guppy_from_patch(patch, check_plan="szz_current_v1") + assert "Check plan: szz_current_v1" in guppy_source + assert "def pecos_qis_trace_metadata_qubit_hugr(" in guppy_source + assert " = pecos_qis_trace_metadata_qubit_hugr(" in guppy_source + assert _PACKED_TRACE_METADATA_JSON_KEY in guppy_source + records = [metadata for _, metadata in _packed_trace_metadata_records(guppy_source)] + assert any(metadata.get("source_kind") == "szz_host" for metadata in records) + assert any(metadata.get("source_kind") == "szz_data_prefix" for metadata in records) + assert any(str(metadata.get("szz_host_label", "")).startswith("szz:") for metadata in records) + assert any(str(metadata.get("host_id", "")).startswith("szz:") for metadata in records) + assert any(metadata.get("local_role") == "basis_prefix" for metadata in records) + assert any(metadata.get("source_lowering_required") == "true" for metadata in records) + + +def test_szz_guppy_source_can_disable_trace_metadata_for_execution() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + guppy_source = generate_guppy_source( + patch, + num_rounds=1, + interaction_basis="szz", + check_plan="szz_current_v1", + trace_metadata=False, + ) + + assert "def pecos_qis_trace_metadata_qubit_hugr(" not in guppy_source + assert "pecos_qis_trace_metadata_qubit_hugr(" not in guppy_source + assert "zz_phase(" in guppy_source + assert "result(" in guppy_source + + +def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + source = generate_guppy_source( + patch, + check_plan="szz_current_v1", + szz_runtime_barriers="data-prefix", + ) + + lines = source.splitlines() + barrier_index = next(i for i, line in enumerate(lines) if "= pecos_qis_runtime_barrier_qubits2_hugr(" in line) + prefix_index, _ = _first_metadata_line( + source, + start_line=barrier_index, + source_kind="szz_data_prefix", + ) + host_index, _ = _first_metadata_line(source, start_line=prefix_index, source_kind="szz_host") + zz_phase_index = next(i for i in range(barrier_index, len(lines)) if "zz_phase(" in lines[i]) + + assert barrier_index < prefix_index < host_index < zz_phase_index + + +def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + ) + + prefix_index, prefix_metadata = _first_metadata_line( + source, + source_kind="szz_data_prefix", + ) + host_index, host_metadata = _first_metadata_line( + source, + start_line=prefix_index + 1, + source_kind="szz_host", + ) + + assert prefix_metadata["local_role"] == "basis_prefix" + assert prefix_metadata["host_id"].startswith("szz:") + assert host_metadata["host_id"].startswith("szz:") + assert prefix_index < host_index + + +def test_szz_hosted_metadata_labels_include_helper_scope() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + ) + + assert _has_metadata_prefix(source, "host_id", "szz:init_z_basis:") + assert _has_metadata_prefix(source, "host_id", "szz:init_x_basis:") + assert _has_metadata_prefix(source, "host_id", "szz:syndrome_extraction:") + + +def test_plain_szz_memory_source_unrolls_hosted_metadata_by_counted_round() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + num_rounds=2, + ) + + assert "for _t in range(comptime(num_rounds))" not in source + assert "if num_rounds != 2:" in source + assert "def syndrome_extraction_memory_r0" in source + assert "def syndrome_extraction_memory_r1" in source + assert _has_metadata_prefix(source, "host_id", "szz:memory_r0:") + assert _has_metadata_prefix(source, "host_id", "szz:memory_r1:") + assert not _has_metadata_prefix(source, "host_id", "szz:memory_r2:") + + +def test_plain_szz_memory_cache_key_includes_counted_rounds() -> None: + from pecos.guppy.surface import _guppy_module_cache_key + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + + key_one = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + num_rounds=1, + ) + key_two = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + num_rounds=2, + ) + key_generic = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + ) + + assert key_one.endswith("_r1") + assert key_two.endswith("_r2") + assert key_one != key_two + assert key_generic not in {key_one, key_two} + + +def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import ( + OpType, + build_surface_code_circuit, + generate_guppy_from_patch, + generate_tick_circuit_from_patch, + ) + + patch = SurfacePatch.create(distance=3) + + current_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + boundary_first_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + check_plan="szz_boundary_first_v1", + ) + + def szz_gate_signature(ops: object) -> list[tuple[str, tuple[int, ...], str]]: + return [(op.op_type.name, tuple(op.qubits), op.label) for op in ops if op.op_type in {OpType.SZZ, OpType.SZZDG}] + + assert szz_gate_signature(boundary_first_ops) != szz_gate_signature(current_ops) + assert sum(op.op_type == OpType.SZZDG for op in boundary_first_ops) == sum( + op.op_type == OpType.SZZDG for op in current_ops + ) + + current_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + boundary_first_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_boundary_first_v1", + ) + assert boundary_first_tick.get_meta("detectors") == current_tick.get_meta("detectors") + assert boundary_first_tick.get_meta("observables") == current_tick.get_meta("observables") + + source = generate_guppy_from_patch(patch, check_plan="szz_boundary_first_v1") + assert "Check plan: szz_boundary_first_v1" in source + + +def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch + from pecos.qec.surface.decode import build_memory_circuit + + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match="conflicts with check_plan"): + generate_tick_circuit_from_patch( + patch, + num_rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + with pytest.raises(ValueError, match="conflicts with check_plan"): + build_memory_circuit( + patch=patch, + rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_native_sampler_records_resolved_check_plan() -> None: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p2=0.001), + check_plan="szz_current_v1", + sampling_model="influence_dem", + ) + + assert sampler.interaction_basis == "szz" + assert sampler.check_plan == "szz_current_v1" + assert sampler.resolved_check_plan is not None + assert sampler.resolved_check_plan["semantic_content"]["interaction_basis"] == "szz" + assert len(sampler.resolved_check_plan_hash) == 64 diff --git a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py index 976829c83..e8fd46fb6 100644 --- a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py +++ b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py @@ -594,9 +594,9 @@ def test_pecos_dem_decode_memory(self, patch): # At d=3 p=0.001, LER should be very low assert ler < 0.05, f"LER too high: {ler}" - def test_observable_subgraph_decoder(self, patch, nq): - """OSD with PECOS DEM on CX circuit.""" - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + def test_logical_subgraph_decoder(self, patch, nq): + """logical-subgraph decoder with PECOS DEM on CX circuit.""" + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -607,10 +607,10 @@ def test_observable_subgraph_decoder(self, patch, nq): dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pecos_uf:fast") - assert osd.num_observables() == 2 + decoder = LogicalSubgraphDecoder(dem_str, sc, "pecos_uf:fast") + assert decoder.num_observables() == 2 - sizes = osd.subgraph_sizes() + sizes = decoder.subgraph_sizes() for s in sizes: assert s > 0, "Empty subgraph" @@ -812,17 +812,17 @@ def test_noisy_random_composition(self, patch, nq, seed): # --------------------------------------------------------------------------- -# OSD accuracy comparison +# logical-subgraph decoder accuracy comparison # --------------------------------------------------------------------------- -class TestOSDAccuracy: +class TestLogicalSubgraphAccuracy: """Compare observable subgraph decoder accuracy against baseline.""" - def test_osd_better_than_naive_on_cx(self, patch, nq): - """OSD should outperform naive decomposed MWPM on CX circuits.""" + def test_logical_subgraph_better_than_naive_on_cx(self, patch, nq): + """logical-subgraph decoder should outperform naive decomposed MWPM on CX circuits.""" import stim - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -846,31 +846,34 @@ def test_osd_better_than_naive_on_cx(self, patch, nq): naive_errors = batch_naive.decode_count(str(dem_decomp), "pecos_uf:fast") naive_ler = naive_errors / 20000 - # OSD with FB + # logical-subgraph decoder with FB sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") - osd_errors = sum( + decoder = LogicalSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") + subgraph_errors = sum( 1 for i in range(20000) - if osd.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) + if decoder.decode(det_events[i].tolist()) + != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) ) - osd_ler = osd_errors / 20000 + subgraph_ler = subgraph_errors / 20000 - # OSD should be at least as good (usually much better) - assert osd_ler <= naive_ler * 1.5 + 0.001, f"OSD ({osd_ler:.5f}) much worse than naive ({naive_ler:.5f})" + # logical-subgraph decoder should be at least as good (usually much better) + assert ( + subgraph_ler <= naive_ler * 1.5 + 0.001 + ), f"logical-subgraph decoder ({subgraph_ler:.5f}) much worse than naive ({naive_ler:.5f})" # --------------------------------------------------------------------------- -# PECOS-native DEM with OSD decoder on CX +# PECOS-native DEM with logical-subgraph decoder decoder on CX # --------------------------------------------------------------------------- -class TestPecosDemWithOSD: +class TestPecosDemWithLogicalSubgraph: """Test PECOS-native DEM pipeline with observable subgraph decoder.""" - def test_pecos_dem_osd_cx(self, patch, nq): - """PECOS DEM → OSD decoder on CX circuit.""" - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + def test_pecos_dem_logical_subgraph_cx(self, patch, nq): + """PECOS DEM → logical-subgraph decoder decoder on CX circuit.""" + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -885,17 +888,17 @@ def test_pecos_dem_osd_cx(self, patch, nq): errors = [line for line in dem_str.split("\n") if line.startswith("error(")] assert len(errors) > 0 - # Build OSD decoder from PECOS DEM + # Build logical-subgraph decoder decoder from PECOS DEM sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pecos_uf:fast") - assert osd.num_observables() == 2 + decoder = LogicalSubgraphDecoder(dem_str, sc, "pecos_uf:fast") + assert decoder.num_observables() == 2 # Sample and decode parsed = ParsedDem.from_string(dem_str) batch = parsed.to_dem_sampler().generate_samples(5000, seed=42) errors = batch.decode_count(dem_str, "pecos_uf:fast") ler = errors / 5000 - assert ler < 0.1, f"PECOS DEM + OSD CX LER too high: {ler}" + assert ler < 0.1, f"PECOS DEM + logical-subgraph decoder CX LER too high: {ler}" # --------------------------------------------------------------------------- diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py new file mode 100644 index 000000000..401d0fbff --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import pytest +from pecos.qec.surface import ( + LocalCliffordFrame, + NoiseModel, + OpType, + SignedPauli, + SurfacePatch, + build_memory_circuit, + build_surface_code_circuit, + generate_tick_circuit_from_patch, + global_surface_frame, + resolve_surface_clifford_frame, +) +from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + +def test_identity_frame_resolves_to_css_surface_checks() -> None: + patch = SurfacePatch.create(distance=3) + + resolved = resolve_surface_clifford_frame(patch, policy="identity") + + assert not resolved.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved.x_checks} == {"X"} + assert {check.uniform_axis for check in resolved.z_checks} == {"Z"} + assert resolved.logical_x.uniform_axis == "X" + assert resolved.logical_z.uniform_axis == "Z" + assert resolved.css_physical_memory_basis("X") == "X" + assert resolved.css_physical_memory_basis("Z") == "Z" + + +def test_global_h_frame_resolves_to_css_basis_swap() -> None: + patch = SurfacePatch.create(distance=3) + + resolved = resolve_surface_clifford_frame(patch, policy="global-h") + + assert not resolved.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved.x_checks} == {"Z"} + assert {check.uniform_axis for check in resolved.z_checks} == {"X"} + assert resolved.logical_x.uniform_axis == "Z" + assert resolved.logical_z.uniform_axis == "X" + assert resolved.css_physical_memory_basis("X") == "Z" + assert resolved.css_physical_memory_basis("Z") == "X" + + +def test_axis_cycle_frames_resolve_but_require_deformed_checks() -> None: + patch = SurfacePatch.create(distance=3) + + resolved_f = resolve_surface_clifford_frame(patch, policy="global_axis_cycle_f") + resolved_f2 = resolve_surface_clifford_frame(patch, policy="global_axis_cycle_f2") + + assert resolved_f.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved_f.x_checks} == {"Y"} + assert {check.uniform_axis for check in resolved_f.z_checks} == {"X"} + assert resolved_f.logical_x.uniform_axis == "Y" + assert resolved_f.logical_z.uniform_axis == "X" + + assert resolved_f2.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved_f2.x_checks} == {"Z"} + assert {check.uniform_axis for check in resolved_f2.z_checks} == {"Y"} + assert resolved_f2.logical_x.uniform_axis == "Z" + assert resolved_f2.logical_z.uniform_axis == "Y" + + with pytest.raises(NotImplementedError, match="deformed check synthesis"): + resolved_f.css_physical_memory_basis("Z") + with pytest.raises(NotImplementedError, match="deformed check synthesis"): + resolved_f2.css_physical_memory_basis("X") + + +def test_checkerboard_frames_resolve_to_mixed_xzzx_zxxz_checks() -> None: + patch = SurfacePatch.create(distance=3) + + xzzx = resolve_surface_clifford_frame(patch, policy="checkerboard-xzzx") + zxxz = resolve_surface_clifford_frame(patch, policy="checkerboard_zxxz") + + assert xzzx.requires_deformed_check_synthesis + assert zxxz.requires_deformed_check_synthesis + assert {check.axes for check in xzzx.checks if len(check.axes) == 4} == { + ("X", "Z", "Z", "X"), + } + assert {check.axes for check in zxxz.checks if len(check.axes) == 4} == { + ("Z", "X", "X", "Z"), + } + assert not xzzx.logical_x.is_uniform_axis + assert not xzzx.logical_z.is_uniform_axis + assert not zxxz.logical_x.is_uniform_axis + assert not zxxz.logical_z.is_uniform_axis + + +def test_explicit_mixed_local_frame_marks_only_mixed_checks_deformed() -> None: + patch = SurfacePatch.create(distance=3) + frames = list(global_surface_frame("identity", patch.num_data)) + frames[0] = LocalCliffordFrame(SignedPauli("Z"), SignedPauli("X")) + + resolved = resolve_surface_clifford_frame( + patch, + policy="identity", + data_frames=frames, + ) + + assert resolved.requires_deformed_check_synthesis + assert any(check.requires_deformed_check_synthesis for check in resolved.checks) + assert any(not check.is_uniform_axis for check in resolved.checks) + assert any(check.axes == ("Z", "X") for check in resolved.x_checks if len(check.axes) == 2) + + +def test_rejects_frame_map_with_wrong_length() -> None: + patch = SurfacePatch.create(distance=3) + frames = global_surface_frame("identity", patch.num_data - 1) + + with pytest.raises(ValueError, match=r"does not match patch\.num_data"): + resolve_surface_clifford_frame(patch, data_frames=frames) + + +def test_global_axis_cycle_f_emits_uniform_y_szz_check_scaffold() -> None: + patch = SurfacePatch.create(distance=3) + + ops, _allocation = build_surface_code_circuit( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert any(op.op_type == OpType.SXDG and "szz_y_touch_pre:X" in op.label for op in ops) + assert any(op.op_type == OpType.SX and "szz_y_touch_post:X" in op.label for op in ops) + assert any(op.op_type == OpType.SYDG and "szz_touch_comp:SYDG:Y:X" in op.label for op in ops) + assert any(op.op_type == OpType.H and op.label == "prep_x_basis_d0:to_z" for op in ops) + assert any(op.op_type == OpType.H and op.label == "measure_x_basis_d0:from_z" for op in ops) + assert any(op.op_type == OpType.MEASURE and op.label.startswith("sx") for op in ops) + assert any(op.op_type == OpType.MEASURE and op.label.startswith("sz") for op in ops) + + +@pytest.mark.parametrize( + ("policy", "rotated_data", "unrotated_data", "x_touch_data", "z_touch_data"), + [ + ("checkerboard_xzzx", 0, 1, 1, 2), + ("checkerboard_zxxz", 1, 0, 2, 1), + ], +) +def test_checkerboard_frames_emit_mixed_szz_check_scaffold( + policy: str, + rotated_data: int, + unrotated_data: int, + x_touch_data: int, + z_touch_data: int, +) -> None: + patch = SurfacePatch.create(distance=3) + + ops, _allocation = build_surface_code_circuit( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy=policy, + ) + + assert any(op.op_type == OpType.H and op.label == f"prep_x_basis_d{rotated_data}:to_z" for op in ops) + assert any(op.op_type == OpType.H and op.label == f"measure_x_basis_d{rotated_data}:from_z" for op in ops) + assert not any(op.label == f"prep_x_basis_d{unrotated_data}:to_z" for op in ops) + assert any(f"szz_x_touch_pre:X1:d{x_touch_data}:to_z" in op.label for op in ops) + assert any(f"szz_touch_comp:SXDG:X:X1:d{x_touch_data}" in op.label for op in ops) + assert any(f"szz_touch_comp:SZDG:Z:X1:d{z_touch_data}" in op.label for op in ops) + + +def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + detectors = tick_circuit.get_meta("detectors") + observables = tick_circuit.get_meta("observables") + assert detectors + assert observables + assert tick_circuit.get_meta("basis") == "Z" + + +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_tick_circuit_keeps_source_detector_metadata(policy: str) -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy=policy, + ) + + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") + + +def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> None: + patch = SurfacePatch.create(distance=3) + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + basis="Z", + circuit_source="abstract", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert isinstance(dem, str) + + +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_native_abstract_dem_path_accepts_frame_policy(policy: str) -> None: + patch = SurfacePatch.create(distance=3) + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + basis="Z", + circuit_source="abstract", + interaction_basis="szz", + clifford_frame_policy=policy, + ) + + assert isinstance(dem, str) + + +def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: + tick_circuit = build_memory_circuit( + distance=3, + rounds=1, + basis="Z", + circuit_source="traced_qis", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert tick_circuit.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") + + +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_traced_qis_binds_runtime_result_tags(policy: str) -> None: + tick_circuit = build_memory_circuit( + distance=3, + rounds=1, + basis="Z", + circuit_source="traced_qis", + interaction_basis="szz", + clifford_frame_policy=policy, + ) + + assert tick_circuit.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py new file mode 100644 index 000000000..2e506748a --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py @@ -0,0 +1,132 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Honest windowing-mode reporting for the logical-circuit decoder (Layer 0). + +The windowed-budget path used to silently perform a single-window full decode +(the per-observable sub-DEMs were serialized without detector coordinates, so the +inner windowed decoder degenerated to one window) while the API advertised a +bounded-latency budget. This pins that the effective mode is now surfaced +explicitly: ``effective_windowing`` / ``actual_num_windows`` are introspectable, +``can_window`` distinguishes "real windowing is possible" from "real windowing is +enabled", and a ``strict`` request hard-errors instead of silently falling back. + +See pecos-docs/design/windowed-logical-subgraph-proper-solution.md. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalCircuitDecoder + + +def _memory_descriptor(d: int, rounds: int) -> dict: + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + return b.build_algorithm_descriptor(p1=0.001, p2=0.001, p_meas=0.001) + + +def _h_boundary_descriptor() -> dict: + patch = SurfacePatch.create(3) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + b.add_transversal_h("A") + b.add_memory("A", 3, "X") + desc = b.build_algorithm_descriptor(p1=0.001, p2=0.001, p_meas=0.001) + assert desc["boundary_gates"][0][0]["type"] == "Hadamard" + return desc + + +def test_unlimited_budget_reports_unlimited(): + dec = LogicalCircuitDecoder(_memory_descriptor(3, 9), budget="unlimited") + assert dec.effective_windowing == "unlimited" + assert dec.can_window is False + assert dec.actual_num_windows == [] + + +def test_windowed_budget_is_explicit_full_fallback_not_silent(): + """The windowed budget must NOT silently claim bounded latency: it reports a + full-decode fallback with one window per observable, while still signalling + that genuine windowing is possible for this (deep enough) circuit.""" + dec = LogicalCircuitDecoder(_memory_descriptor(3, 9), budget="windowed") + assert dec.effective_windowing == "full_fallback" + assert len(dec.actual_num_windows) >= 1 + assert all(n == 1 for n in dec.actual_num_windows) + # The circuit is deep enough that real windowing *could* happen (coords are + # preserved in the plan); it is just not enabled until the anti-snake work. + assert dec.can_window is True + + +def test_strict_windowed_budget_hard_errors(): + """With strict=True, an unmet bounded-latency budget is a hard error rather + than a silent full-decode fallback.""" + desc = _memory_descriptor(3, 9) + with pytest.raises(Exception, match="strict"): + LogicalCircuitDecoder(desc, budget="windowed", strict=True) + + +def test_strict_accepts_shallow_circuit_using_real_distance(): + """`can_window`/`strict` must use the REAL physical code distance from the + descriptor, not a fake distance derived from the patch count. A single d=5 + patch with only 2 rounds is one window at step=d=5, so strict=True must NOT + reject and can_window must be False. (The prior code derived distance=1 from + the 1-patch count and wrongly reported real windowing / rejected.)""" + desc = _memory_descriptor(5, 2) + assert desc["distance"] == 5 + dec = LogicalCircuitDecoder(desc, budget="windowed", strict=True) # must not raise + assert dec.can_window is False + assert dec.effective_windowing == "full_fallback" + + +def test_windowed_full_fallback_still_decodes(): + """The full-fallback path is still a working decoder (accurate per-observable + decode), not a stub.""" + desc = _memory_descriptor(3, 9) + dec = LogicalCircuitDecoder(desc, budget="windowed") + ndet = sum(1 for ln in desc["full_dem"].splitlines() if ln.strip().startswith("detector(")) + # Zero syndrome -> zero correction. + assert dec.decode([0] * ndet) == 0 + + +def test_logical_circuit_decoder_rejects_empty_segments(): + """Malformed algorithm descriptors should raise a Python error, not panic.""" + desc = _memory_descriptor(3, 3) + desc["segments"] = [] + + with pytest.raises(ValueError, match="no segments"): + LogicalCircuitDecoder(desc, budget="unlimited") + + +def test_logical_circuit_decoder_rejects_missing_boundary_gate_bit(): + """Boundary gate descriptors must fail loudly when required bit fields are absent.""" + desc = _h_boundary_descriptor() + del desc["boundary_gates"][0][0]["x_obs_bit"] + + with pytest.raises(ValueError, match="missing required field 'x_obs_bit'"): + LogicalCircuitDecoder(desc, budget="unlimited") + + +def test_logical_circuit_decoder_rejects_out_of_range_boundary_gate_bit(): + """Boundary gate bits index a u64 observable frame and must be below 64.""" + desc = _h_boundary_descriptor() + desc["boundary_gates"][0][0]["x_obs_bit"] = 64 + + with pytest.raises(ValueError, match="exceeds the 64-observable frame limit"): + LogicalCircuitDecoder(desc, budget="unlimited") + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py new file mode 100644 index 000000000..ae41cff8c --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py @@ -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. + +"""Differential test: PECOS LogicalSubgraphDecoder observing-region selection +vs the reference algorithm shipped by lomatching. + +PECOS's `coordinate_membership_from_dem` (exposed via +`LogicalSubgraphDecoder.observing_regions`) is the same boundary-edge + +stabilizer-coordinate vertex selection that lomatching ships for its MWPM +subgraphs (Serra-Peralta et al., arXiv:2505.13599). The papers justify the +construction via Clifford back-propagation, but the shipping decoder uses this +coordinate path -- so PECOS should agree with it detector-for-detector. + +The oracle below is a faithful port of +``lomatching.util.get_detector_indices_for_subgraphs`` +(``~/Repos/lomatching/lomatching/util.py:255-353``), depending only on stim so +the full lomatching decoder stack (numba/galois/ldpc/pymatching) is not required. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalSubgraphDecoder + +stim = pytest.importorskip("stim", reason="stim is the differential-test oracle dependency") + + +def _lomatching_reference_membership(dem_str: str, stab_coords) -> list[list[int]]: + """Faithful port of lomatching's ``get_detector_indices_for_subgraphs``. + + Returns, per observable (sorted by observable index), the sorted detector + ids in that observable's observing region. Reference: + Serra-Peralta et al. arXiv:2505.13599; lomatching/util.py:255-353. + """ + dem = stim.DetectorErrorModel(dem_str).flattened() + + det_to_coords = {d: tuple(map(float, c)) for d, c in dem.get_detector_coordinates().items()} + coords_to_det = {c: d for d, c in det_to_coords.items()} + + # spatial coord (all but the trailing time element) -> (logical qubit, stab type) + coords_to_stab: dict[tuple, tuple[int, str]] = {} + for l_ind, qubit in enumerate(stab_coords): + for stab_type in ("X", "Z"): + for coord in qubit[stab_type]: + coords_to_stab[tuple(map(float, coord))] = (l_ind, stab_type) + + # boundary edges = single-detector error mechanisms that flip an observable + bd_edges_obs: dict[int, list[int]] = {o: [] for o in range(dem.num_observables)} + for instr in dem: + if instr.type != "error": + continue + dets = [t.val for t in instr.targets_copy() if t.is_relative_detector_id()] + if len(dets) != 1: + continue + for o in (t.val for t in instr.targets_copy() if t.is_logical_observable_id()): + bd_edges_obs[o] += dets + + # (logical qubit, stab type, time) seeds for each observable + lst_obs: dict[int, set[tuple[int, str, float]]] = {o: set() for o in range(dem.num_observables)} + for obs, dets in bd_edges_obs.items(): + for det in dets: + coords = det_to_coords[det] + l_ind, stab = coords_to_stab[coords[:-1]] + lst_obs[obs].add((l_ind, stab, coords[-1])) + + # include every detector of those (qubit, stab, time) groups + membership: list[list[int]] = [] + for obs in sorted(lst_obs): + inds: list[int] = [] + for l_ind, stab, time in lst_obs[obs]: + for c in stab_coords[l_ind][stab]: + coord = (*map(float, c), time) + inds.append(coords_to_det[coord]) + membership.append(sorted(inds)) + return membership + + +def _assert_parity(dem_str: str, stab_coords) -> None: + decoder = LogicalSubgraphDecoder(dem_str, stab_coords, "pecos_uf:fast") + pecos = [sorted(r) for r in decoder.observing_regions()] + reference = _lomatching_reference_membership(dem_str, stab_coords) + + assert len(pecos) == len(reference), f"observable count mismatch: PECOS {len(pecos)} vs lomatching {len(reference)}" + for obs, (p, r) in enumerate(zip(pecos, reference, strict=True)): + assert p == r, f"observable {obs} membership differs:\n PECOS={p}\n lomatching={r}" + + +def test_parity_memory_z(): + """Single-patch Z memory: PECOS regions == lomatching regions.""" + b = LogicalCircuitBuilder() + b.add_patch(SurfacePatch.create(distance=3), "A") + b.add_memory("A", 3, "Z") + dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + _assert_parity(dem_str, b.stab_coords()) + + +def test_parity_transversal_cx(): + """Two-patch transversal CX (the hyperedge case): regions must agree.""" + patch = SurfacePatch.create(distance=3) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 3, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 3, "Z") + dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + _assert_parity(dem_str, b.stab_coords()) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py new file mode 100644 index 000000000..bc92b49ea --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -0,0 +1,412 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Compare observing-region constructions for the logical-subgraph decoder. + +The decoder's accuracy depends on which detectors land in each observable's +subgraph. Two constructions: + +- **coordinate group-fill** (the shipping path, == lomatching's + ``get_detector_indices_for_subgraphs``): boundary edges seed + ``(qubit, stab_type, time)`` groups, then ALL detectors of those groups are + included. +- **back-propagation / co-flipped** (the papers' derivation, arXiv:2505.13587): + detectors that share a fault with the observable -- i.e. lie in its + detecting region. Computed here directly from the DEM ``L`` targets. + +`LogicalSubgraphDecoder.from_membership` lets both feed the same decoder, so we +can compare. Finding (recorded in +``pecos-docs/design/logical-subgraph-backprop-region-builder.md``): the raw +back-prop set decodes much WORSE despite being larger, because it lacks the +group-fill structure that makes each subgraph cleanly matchable. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import ( + LogicalSubgraphDecoder, + ParsedDem, + WindowedLogicalSubgraphDecoder, +) + + +def _coflip_membership_from_dem(dem_str: str, num_observables: int) -> list[list[int]]: + """Back-propagation / co-flipped observing region, read straight off the DEM. + + For each observable O, the detectors of every error mechanism that flips O + (an error in their shared support flips both -- O's detecting region). + """ + regions: list[set[int]] = [set() for _ in range(num_observables)] + for raw_line in dem_str.splitlines(): + line = raw_line.strip() + if not line.startswith("error("): + continue + tokens = line[line.index(")") + 1 :].split() + dets = [int(t[1:]) for t in tokens if t.startswith("D")] + obs = [int(t[1:]) for t in tokens if t.startswith("L")] + for o in obs: + regions[o].update(dets) + return [sorted(r) for r in regions] + + +def _groupfill_membership(dem_str: str, stab_coords, *, seed_all: bool) -> list[list[int]]: + """Coordinate group-fill membership, parameterized by the seed rule. + + ``seed_all=False`` seeds only 1-detector boundary edges (the shipping + coordinate path / lomatching). ``seed_all=True`` seeds from every detector of + any O-flipping mechanism (the back-propagation / detecting-region crossings), + then group-fills the same way -- a strictly broader region. + """ + from collections import defaultdict + + det_coords: dict[int, tuple[float, ...]] = {} + mechs: list[tuple[list[int], list[int]]] = [] + for raw in dem_str.splitlines(): + ln = raw.strip() + if ln.startswith("detector("): + coords = tuple(float(x) for x in ln[ln.index("(") + 1 : ln.index(")")].split(",")) + for t in ln[ln.index(")") + 1 :].split(): + if t.startswith("D"): + det_coords[int(t[1:])] = coords + elif ln.startswith("error("): + toks = ln[ln.index(")") + 1 :].split() + mechs.append( + ([int(t[1:]) for t in toks if t.startswith("D")], [int(t[1:]) for t in toks if t.startswith("L")]), + ) + + coords_to_stab: dict[tuple, tuple[int, str]] = {} + for li, q in enumerate(stab_coords): + for st in ("X", "Z"): + for c in q[st]: + coords_to_stab[tuple(map(float, c))] = (li, st) + + det_group: dict[int, tuple] = {} + group_dets: dict[tuple, list[int]] = defaultdict(list) + for d, c in det_coords.items(): + spatial, time = c[:-1], c[-1] + if spatial in coords_to_stab: + li, st = coords_to_stab[spatial] + det_group[d] = (li, st, time) + group_dets[(li, st, time)].append(d) + + nobs = 1 + max((o for _, obs in mechs for o in obs), default=-1) + seeds: list[set] = [set() for _ in range(nobs)] + for dets, obs in mechs: + if not obs: + continue + seed_dets = dets if (seed_all or len(dets) == 1) else [] + for o in obs: + for d in seed_dets: + if d in det_group: + seeds[o].add(det_group[d]) + return [sorted({d for g in seeds[o] for d in group_dets[g]}) for o in range(nobs)] + + +def _cx_circuit(): + patch = SurfacePatch.create(distance=3) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 3, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 3, "Z") + return b + + +def test_from_membership_reproduces_coordinate_path(): + """Feeding the coordinate membership through from_membership reproduces the + normal coordinate decoder exactly (the seam is behaviour-preserving).""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + rebuilt = LogicalSubgraphDecoder.from_membership( + dem, + coord.observing_regions(), + "pecos_uf:fast", + ) + + assert rebuilt.subgraph_sizes() == coord.subgraph_sizes() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(2000, seed=3) + assert rebuilt.decode_count(batch) == coord.decode_count(batch) + + +def test_coordinate_region_beats_raw_backprop_region(): + """The coordinate group-fill region decodes far better than the raw + back-prop / co-flipped detector set -- group-fill is essential, not + cosmetic.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + n = 20000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=11) + + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + coflip_membership = _coflip_membership_from_dem(dem, coord.num_observables()) + backprop = LogicalSubgraphDecoder.from_membership(dem, coflip_membership, "pecos_uf:fast") + + coord_ler = coord.decode_count(batch) / n + backprop_ler = backprop.decode_count(batch) / n + + # The coordinate group-fill region is dramatically better. The gap is large + # and stable (~20x at d=3, p=0.001); assert a conservative margin. + assert ( + coord_ler < backprop_ler + ), f"expected coordinate region to beat raw back-prop: coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + assert ( + coord_ler * 5 < backprop_ler + ), f"expected a large gap (group-fill essential): coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + + +def test_coordinate_seeding_reproduces_shipping_path(): + """The group-fill helper with boundary-edge seeding reproduces the shipping + coordinate membership exactly (validates the helper used for the broader + back-prop comparison).""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + helper = _groupfill_membership(dem, sc, seed_all=False) + assert helper == [sorted(r) for r in coord.observing_regions()] + + +def test_coordinate_beats_backprop_seeded_groupfill(): + """The faithful 'next step': seed the same group-fill from the operator's + back-propagation crossings (all O-flipping mechanism detectors) instead of + boundary edges. It is strictly broader than the coordinate region and + decodes worse -- confirming the boundary-edge seeding IS the right (faithful) + back-propagation region, and broadening hurts.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + coord_membership = _groupfill_membership(dem, sc, seed_all=False) + backprop_membership = _groupfill_membership(dem, sc, seed_all=True) + + # Coordinate region is a strict subset of the back-prop-seeded region. + for c, bp in zip(coord_membership, backprop_membership, strict=True): + assert set(c) <= set(bp) + assert sum(len(bp) for bp in backprop_membership) > sum(len(c) for c in coord_membership) + + n = 20000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=13) + coord = LogicalSubgraphDecoder.from_membership(dem, coord_membership, "pecos_uf:fast") + backprop = LogicalSubgraphDecoder.from_membership(dem, backprop_membership, "pecos_uf:fast") + coord_ler = coord.decode_count(batch) / n + backprop_ler = backprop.decode_count(batch) / n + assert coord_ler < backprop_ler, f"coord={coord_ler:.5f} backprop-seeds={backprop_ler:.5f}" + + +def _mem_ler(d, p, n, seed, inner=None): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", d, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + dec = LogicalSubgraphDecoder(dem, sc) if inner is None else LogicalSubgraphDecoder(dem, sc, inner) + return dec.decode_count(batch) / n + + +def test_distance_suppression_memory(): + """A fault-tolerant decoder must drive LER DOWN as code distance grows below + threshold. The default inner (`fusion_blossom_serial`, exact MWPM) suppresses, + matching lomatching (d=7 -> 0). Guards against the default reverting to a + non-suppressing inner.""" + p, n = 0.001, 60000 + ler_d3 = _mem_ler(3, p, n, seed=1) + ler_d5 = _mem_ler(5, p, n, seed=1) + # Below threshold, d=5 must beat d=3 by a clear margin (lomatching: ~13x). + assert ler_d5 < ler_d3 * 0.7, f"no distance suppression with default inner: d3={ler_d3:.5f} d5={ler_d5:.5f}" + + +def test_native_bp_uf_inner_suppresses(): + """The native `pecos_uf:bp` inner (belief-propagation + union-find) achieves + distance suppression, as a fault-tolerant decoder must. (It is the + dependency-free native option; the decoder *default* is exact MWPM + `fusion_blossom_serial`, which is more accurate and faster at depth -- see + pecos-docs/design/lomatching-paper-additional-learnings.md.) + + Context: the UF predecoder used to mis-decode isolated defects whose + minimum-weight correction is a bulk *path* to the boundary (it only looked at + direct boundary edges, returning a no-flip / wrong-edge result). That was a + catastrophic single-defect bug; fixed by making the predecoder fall through to + the full grow+peel decoder unless its shortcut is provably optimal (see + `predecode_single` / size-2 handling in pecos-uf-decoder). + + NOTE: pure `pecos_uf:fast` (no belief propagation) was also improved by the + predecoder fix but its full grow+peel heuristic does NOT robustly suppress at + depth -- a separate, lesser weakness, which is why the native option is + `pecos_uf:bp`, not `pecos_uf:fast`. + See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" + p, n = 0.001, 60000 + uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") + uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") + # Below threshold, d=5 must beat d=3 by a clear margin. + assert uf_d5 < uf_d3 * 0.7, f"default bp+uf inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" + + +def _mem_dem_batch(d, p, n, seed): + """Build a memory DEM + stab_coords + a sample batch (shared fixture for the + non-stochastic default/parallel regressions below).""" + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", d, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + return dem, sc, batch + + +def test_default_inner_is_fusion_blossom_serial(): + """Pin the constructor default backend deterministically (no stochastic LER + margin): the default-built decoder reports ``fusion_blossom_serial`` as its + inner, and decodes identically to one built with that inner explicitly. + Guards the default-flip decision recorded in + pecos-docs/design/lomatching-paper-additional-learnings.md.""" + dem, sc, batch = _mem_dem_batch(5, p=0.003, n=4000, seed=7) + default = LogicalSubgraphDecoder(dem, sc) + explicit = LogicalSubgraphDecoder(dem, sc, "fusion_blossom_serial") + assert default.inner_decoder == "fusion_blossom_serial" + assert default.decode_count(batch) == explicit.decode_count(batch) + + +def test_decode_count_parallel_matches_serial_default(): + """Finding #1 regression: `decode_count_parallel` must reuse the inner + backend chosen at construction (not silently fall back to a different + default), so the parallel path agrees with the serial `decode_count` on the + same shots. Previously the parallel helper defaulted to `pymatching` + regardless of the constructor's inner.""" + dem, sc, batch = _mem_dem_batch(5, p=0.003, n=4000, seed=11) + dec = LogicalSubgraphDecoder(dem, sc) + serial = dec.decode_count(batch) + parallel = dec.decode_count_parallel(batch, dem, sc) + assert serial == parallel + + +def _windowed_mem_ler(d, rounds, p, n, seed, step, buffer): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + dec = WindowedLogicalSubgraphDecoder(dem, sc, step, buffer) + return dec.decode_count(batch) / n, dec.num_windows() + + +def _nonwindowed_mem_ler(d, rounds, p, n, seed, inner): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + return LogicalSubgraphDecoder(dem, sc, inner).decode_count(batch) / n + + +def test_windowed_logical_subgraph_single_window_matches_nonwindowed(): + """Correctness guarantee for the windowed decoder's construction: with a + window larger than the circuit depth (a single window, all-core), the + windowed logical-subgraph decoder reproduces the non-windowed decoder with + the same native union-find inner. + + This pins that the per-observable subgraph serialization, the local<->global + detector mapping, and the local-bit-0 -> global-observable-bit remapping are + all correct -- independently of the time-windowing behaviour exercised + below.""" + p, n, rounds = 0.001, 40000, 18 + for d in (3, 5): + win, nwin = _windowed_mem_ler(d, rounds, p, n, seed=7, step=10_000, buffer=0) + non = _nonwindowed_mem_ler(d, rounds, p, n, seed=7, inner="pecos_uf:fast") + assert nwin == 1, f"expected a single window, got {nwin}" + # Same decode up to negligible tie-breaking differences. + assert abs(win - non) <= max( + 0.0005, + 0.15 * non, + ), f"single-window windowed != non-windowed at d={d}: win={win:.5f} non={non:.5f}" + + +def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): + """KNOWN LIMITATION (separately tracked): the windowed logical-subgraph + decoder does not yet achieve full distance suppression on memory. + + The decoder was rewritten to do proper sliding-window core-commit (each + per-observable subgraph is wrapped in an ``OverlappingWindowedDecoder``, which + commits only correction edges whose both endpoints lie in a window's core). + That removed the old double-counting bug -- a single window now reproduces the + non-windowed decoder exactly (see the test above), and multi-window LER is no + longer catastrophic (the old naive-XOR decoder anti-suppressed to ~10-25%). + + What remains is the *windowed logical-observable-matching* limitation + identified in Serra-Peralta et al. (arXiv:2505.13599, Sec. V): per-observable + windowing admits "time-like snake" error patterns that scale sublinearly in + d, so LER does not fully suppress without their additional machinery + (synchronized resets every Omega(d) and/or a two-step decoder with short-cut + edges). Standard *full-DEM* sliding-window decoding does not have this issue + (PECOS's ``windowed:`` decoder suppresses on a graphlike/Stim-decomposed DEM + -- it is not exercised here because the native PECOS DEM has undecomposed + hyperedges that the full-DEM windowed path's matching inner rejects). For a + single-observable memory prefer either the non-windowed + ``LogicalSubgraphDecoder`` or full-DEM windowing on a decomposed DEM. This + pins the limitation; implementing the anti-snake machinery is the remaining + work. + See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" + p, n, rounds = 0.001, 40000, 18 + ler_d3, nwin = _windowed_mem_ler(3, rounds, p, n, seed=7, step=3, buffer=3) + ler_d5, _ = _windowed_mem_ler(5, rounds, p, n, seed=7, step=5, buffer=5) + ler_d7, _ = _windowed_mem_ler(7, rounds, p, n, seed=7, step=7, buffer=7) + # The circuit is deep enough to actually exercise windowing. + assert nwin > 1, f"probe degenerated to a single window (nwin={nwin})" + # Still does not fully suppress (the paper's windowed-LOM limitation): LER does + # not fall with distance (it in fact grows). When the anti-snake machinery + # lands and this suppresses, flip the assertions and update the test. + suppression_msg = ( + f"windowed logical-subgraph now suppresses (d3={ler_d3:.5f} " + f"d5={ler_d5:.5f} d7={ler_d7:.5f}) -- anti-snake machinery appears to " + "have landed; update this test." + ) + assert ler_d5 >= ler_d3 * 0.7, suppression_msg + assert ler_d7 >= ler_d5 * 0.7, suppression_msg + # Guard against regressing to the old catastrophic anti-suppression (the + # naive-XOR decoder reached ~0.1-0.25 here); the core-commit rewrite keeps it + # well below that across distances. + assert ( + max(ler_d3, ler_d5, ler_d7) < 0.1 + ), f"windowed LER regressed toward catastrophic: d3={ler_d3:.5f} d5={ler_d5:.5f} d7={ler_d7:.5f}" + + +def test_decode_each_matches_decode_count(): + """`SampleBatch.decode_each` returns per-shot predictions consistent with the + aggregate `decode_count` -- it is the per-shot primitive used to localize + where two decoders disagree.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + n = 3000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=5) + preds = batch.decode_each(dem, "pecos_uf:bp") + assert len(preds) == n + wrong = sum(1 for i, p in enumerate(preds) if p != batch.get_observable_mask(i)) + assert wrong == batch.decode_count(dem, "pecos_uf:bp") + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py new file mode 100644 index 000000000..be804f15f --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -0,0 +1,797 @@ +from __future__ import annotations + +import numpy as np +import pytest +from pecos.qec.surface import ( + GuppyRngMaskConfig, + NoiseModel, + SurfacePatch, + TwirlConfig, + build_memory_circuit, + build_native_sampler, + decode_native_samples, + demask_pauli_frame_records, + extract_detection_events_and_observables, + sample_pauli_activations_from_guppy, + sample_pauli_masks_from_guppy, +) +from pecos.qec.surface._twirl_sites import num_pauli_sites, num_pauli_sites_for_schedule +from pecos.qec.surface.decode import _extract_pauli_activations_from_results, _extract_pauli_masks_from_results + +pytest.importorskip("guppylang") +pytest.importorskip("selene_sim") + + +@pytest.fixture +def patch_d3() -> SurfacePatch: + return SurfacePatch.create(distance=3) + + +def _bool_array_from_indices(rows: list[list[int]], width: int) -> np.ndarray: + out = np.zeros((len(rows), width), dtype=bool) + for shot, indices in enumerate(rows): + for idx in indices: + out[shot, idx] = True + return out + + +def _canonicalize_raw_rows_from_masks( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + raw_rows: list[list[int]], + masks: np.ndarray, +) -> list[list[int]]: + """Apply the same Pauli-frame rule as the generated canonical tracker.""" + num_data = patch.geometry.num_data + init_count = len(patch.geometry.x_stabilizers) if basis.upper() == "Z" else len(patch.geometry.z_stabilizers) + expected_width = init_count + num_rounds * patch.geometry.num_ancilla + num_data + canonical_rows: list[list[int]] = [] + + for shot, raw_row in enumerate(raw_rows): + assert len(raw_row) == expected_width + fx = [False] * num_data + fz = [False] * num_data + row: list[int] = [] + offset = 0 + + for _ in range(init_count): + row.append(int(raw_row[offset])) + offset += 1 + + for r in range(num_rounds): + for stab in patch.geometry.x_stabilizers: + flip = False + for q in stab.data_qubits: + flip ^= fz[q] + row.append(int(bool(raw_row[offset]) ^ flip)) + offset += 1 + for stab in patch.geometry.z_stabilizers: + flip = False + for q in stab.data_qubits: + flip ^= fx[q] + row.append(int(bool(raw_row[offset]) ^ flip)) + offset += 1 + if r < num_rounds - 1: + site_offset = r * num_data + for q in range(num_data): + code = int(masks[shot, site_offset + q]) + fx[q] ^= code in (1, 2) + fz[q] ^= code in (2, 3) + + final_flip = fx if basis.upper() == "Z" else fz + for q in range(num_data): + row.append(int(bool(raw_row[offset]) ^ final_flip[q])) + offset += 1 + + assert offset == expected_width + canonical_rows.append(row) + + return canonical_rows + + +def _run_twirled_guppy_rows_masks_and_raw( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, + twirl: TwirlConfig, +) -> tuple[list[list[int]], np.ndarray, list[list[int]], list[str]]: + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + from selene_sim import SimpleRuntime, Stim, build + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ) + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_twirl_null_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + + sideband_results: dict[str, list[list[int]]] = {} + measurement_rows: list[list[int]] = [] + raw_rows: list[list[int]] = [] + frame_modes: list[str] = [] + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=get_num_qubits(patch=patch, twirl=twirl), + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + row: list[int] = [] + raw_row: list[int] = [] + saw_final = False + saw_raw_final = False + frame_mode: str | None = None + + for name, values in shot_results: + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + + if name.startswith(("pauli_mask:", "pauli_active:")): + sideband_results.setdefault(name, []).append([int(v) for v in shot_value]) + elif name.startswith("frame_mode:"): + assert len(shot_value) == 1 + assert bool(shot_value[0]) + frame_mode = name.removeprefix("frame_mode:") + elif name.startswith("raw:") and ":bit:" in name: + assert len(shot_value) == 1 + raw_row.append(int(shot_value[0])) + elif name == "raw:final": + raw_row.extend(int(v) for v in shot_value) + saw_raw_final = True + elif ":meas:" in name: + assert len(shot_value) == 1 + bit = int(shot_value[0]) + row.append(bit) + if ( + twirl.frame_output == "canonical" + and twirl.site_schedule == "between_rounds" + and ":init:meas:" in name + ): + raw_row.append(bit) + elif name == "final": + row.extend(int(v) for v in shot_value) + saw_final = True + + assert saw_final + assert frame_mode == twirl.frame_output + if twirl.frame_output == "canonical": + assert saw_raw_final + measurement_rows.append(row) + raw_rows.append(raw_row) + frame_modes.append(frame_mode) + + masks = _extract_pauli_masks_from_results( + sideband_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + return measurement_rows, masks, raw_rows, frame_modes + + +def _sample_twirled_guppy_masks_and_activations( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, + twirl: TwirlConfig, +) -> tuple[np.ndarray, np.ndarray]: + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + from selene_sim import SimpleRuntime, Stim, build + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ) + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_twirl_active_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + + sideband_results: dict[str, list[list[int]]] = {} + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=get_num_qubits(patch=patch, twirl=twirl), + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + for name, values in shot_results: + if not name.startswith(("pauli_mask:", "pauli_active:")): + continue + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + sideband_results.setdefault(name, []).append([int(v) for v in shot_value]) + + masks = _extract_pauli_masks_from_results( + sideband_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + activations = _extract_pauli_activations_from_results( + sideband_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + return masks, activations + + +def _run_twirled_guppy_measurement_rows_and_masks( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, +) -> tuple[list[list[int]], np.ndarray]: + rows, masks, _, _ = _run_twirled_guppy_rows_masks_and_raw( + patch, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=rng, + twirl=TwirlConfig(), + ) + return rows, masks + + +def test_runtime_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=3, + num_shots=6, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=11), + ) + + unique_rows = np.unique(masks, axis=0) + assert unique_rows.shape[0] >= 2 + + +def test_runtime_gate_local_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=2, + num_shots=6, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + rng=GuppyRngMaskConfig(seed=12), + ) + + unique_rows = np.unique(masks, axis=0) + assert unique_rows.shape[0] >= 2 + + +def test_scaled_twirl_probability_zero_records_inactive_identity( + patch_d3: SurfacePatch, +) -> None: + twirl = TwirlConfig(twirl_probability=0.0) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=2, + num_shots=3, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=19), + ) + + assert masks.shape == active.shape + assert not masks.any() + assert not active.any() + + +def test_scaled_twirl_records_active_identity_distinct_from_inactive_identity( + patch_d3: SurfacePatch, +) -> None: + twirl = TwirlConfig(twirl_probability=0.5) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=2, + num_shots=16, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=20), + ) + + assert active.any() + assert (~active).any() + assert np.any(active & (masks == 0)) + assert np.any((~active) & (masks == 0)) + assert not np.any((~active) & (masks != 0)) + + +def test_scaled_twirl_fixed_rng_consumption_aligns_same_seed_codes( + patch_d3: SurfacePatch, +) -> None: + common = { + "num_rounds": 2, + "num_shots": 8, + "basis": "Z", + "rng": GuppyRngMaskConfig(seed=21), + } + full_masks, full_active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + twirl=TwirlConfig(twirl_probability=1.0), + **common, + ) + half_masks, half_active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + twirl=TwirlConfig(twirl_probability=0.5), + **common, + ) + + assert full_active.all() + assert half_active.any() + np.testing.assert_array_equal(half_masks[half_active], full_masks[half_active]) + + +def _assert_jeffreys_rate_near( + successes: int, + total: int, + expected: float, + *, + sigma: float = 6.0, +) -> None: + assert total > 0 + alpha = successes + 0.5 + beta = total - successes + 0.5 + mean = alpha / (alpha + beta) + variance = (alpha * beta) / ((alpha + beta) ** 2 * (alpha + beta + 1)) + assert abs(mean - expected) <= sigma * float(np.sqrt(variance)) + + +def test_scaled_twirl_empirical_activation_and_code_rates( + patch_d3: SurfacePatch, +) -> None: + num_rounds = 2 + num_shots = 64 + twirl = TwirlConfig(twirl_probability=0.5) + rng = GuppyRngMaskConfig(seed=22) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=rng, + ) + + public_active = sample_pauli_activations_from_guppy( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=rng, + ) + np.testing.assert_array_equal(public_active, active) + + active_count = int(active.sum()) + total_sites = int(active.size) + _assert_jeffreys_rate_near(active_count, total_sites, 0.5) + + active_codes = masks[active] + assert active_codes.size == active_count + for code in range(4): + _assert_jeffreys_rate_near( + int(np.count_nonzero(active_codes == code)), + active_count, + 0.25, + ) + + +def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: + kwargs = { + "num_rounds": 3, + "num_shots": 6, + "basis": "Z", + "twirl": TwirlConfig(), + "rng": GuppyRngMaskConfig(seed=42), + } + + masks_a = sample_pauli_masks_from_guppy(patch_d3, **kwargs) + masks_b = sample_pauli_masks_from_guppy(patch_d3, **kwargs) + + np.testing.assert_array_equal(masks_a, masks_b) + + +def test_different_seeds_differ(patch_d3: SurfacePatch) -> None: + common = { + "num_rounds": 3, + "num_shots": 8, + "basis": "Z", + "twirl": TwirlConfig(), + } + + masks_seed1 = sample_pauli_masks_from_guppy( + patch_d3, + rng=GuppyRngMaskConfig(seed=1), + **common, + ) + masks_seed2 = sample_pauli_masks_from_guppy( + patch_d3, + rng=GuppyRngMaskConfig(seed=2), + **common, + ) + + assert not np.array_equal(masks_seed1, masks_seed2) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_twirled_theta0_demask_null( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 6 + measurement_rows, masks = _run_twirled_guppy_measurement_rows_and_masks( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=12345), + ) + assert masks.any() + + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=TwirlConfig(), + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + raw_events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + assert not events.any() + assert not observables.any() + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_gate_local_twirled_theta0_demask_null( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 4 + twirl = TwirlConfig(site_schedule="before_two_qubit_gate") + measurement_rows, masks, _, _ = _run_twirled_guppy_rows_masks_and_raw( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=23456), + twirl=twirl, + ) + assert masks.any() + assert masks.shape == ( + num_shots, + num_pauli_sites_for_schedule( + patch_d3, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ) + + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=twirl, + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=twirl, + ) + assert sampler.pauli_frame_lookup is not None + assert sampler.num_pauli_sites == masks.shape[1] + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + raw_events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + assert not events.any() + assert not observables.any() + + +def _assert_canonical_frame_output_matches_lookup( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + seed: int, +) -> None: + twirl = TwirlConfig(frame_output="canonical") + measurement_rows, masks, raw_rows, frame_modes = _run_twirled_guppy_rows_masks_and_raw( + patch, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=seed), + twirl=twirl, + ) + + assert frame_modes == ["canonical"] * num_shots + assert masks.any() + assert raw_rows + + expected_rows = _canonicalize_raw_rows_from_masks( + patch, + basis=basis, + num_rounds=num_rounds, + raw_rows=raw_rows, + masks=masks, + ) + assert measurement_rows == expected_rows + + tick_circuit = build_memory_circuit( + patch=patch, + rounds=num_rounds, + basis=basis, + twirl=TwirlConfig(), + ) + sampler = build_native_sampler( + patch, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + raw_events_per_shot, raw_obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + raw_rows, + ) + raw_events = _bool_array_from_indices(raw_events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(raw_obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + demasked_events, demasked_obs = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + observables = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + np.testing.assert_array_equal(events, demasked_events) + np.testing.assert_array_equal(observables, demasked_obs) + assert not events.any() + assert not observables.any() + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_gate_local_canonical_frame_output_matches_lookup( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 4 + twirl = TwirlConfig(site_schedule="before_two_qubit_gate", frame_output="canonical") + measurement_rows, masks, raw_rows, frame_modes = _run_twirled_guppy_rows_masks_and_raw( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=34567), + twirl=twirl, + ) + + assert frame_modes == ["canonical"] * num_shots + assert masks.any() + assert raw_rows + + abstract_twirl = TwirlConfig(site_schedule="before_two_qubit_gate") + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=abstract_twirl, + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=abstract_twirl, + ) + assert sampler.pauli_frame_lookup is not None + + raw_events_per_shot, raw_obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + raw_rows, + ) + raw_events = _bool_array_from_indices(raw_events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(raw_obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + demasked_events, demasked_obs = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + observables = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + np.testing.assert_array_equal(events, demasked_events) + np.testing.assert_array_equal(observables, demasked_obs) + assert not events.any() + assert not observables.any() + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( + patch_d3: SurfacePatch, + basis: str, +) -> None: + _assert_canonical_frame_output_matches_lookup( + patch_d3, + basis=basis, + num_rounds=2, + num_shots=6, + seed=12345, + ) + + +@pytest.mark.parametrize( + ("distance", "num_rounds", "basis", "num_shots", "seed"), + [ + (3, 3, "Z", 4, 2024), + (3, 3, "X", 4, 2025), + (5, 3, "Z", 2, 2026), + ], +) +def test_runtime_canonical_frame_output_matches_lookup_matrix( + distance: int, + num_rounds: int, + basis: str, + num_shots: int, + seed: int, +) -> None: + patch = SurfacePatch.create(distance=distance) + _assert_canonical_frame_output_matches_lookup( + patch, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + seed=seed, + ) + + +def test_harvested_runtime_masks_drive_fixed_dem_sampler_null(patch_d3: SurfacePatch) -> None: + num_rounds = 3 + num_shots = 8 + twirl = TwirlConfig() + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=5150), + ) + assert masks.any() + + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + assert sampler.pauli_frame_lookup is not None + + raw_events, raw_obs = sampler.sample(num_shots, seed=99, pauli_masks=masks) + assert raw_events.any() or raw_obs.any() + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + assert not events.any() + assert not observables.any() + assert decode_native_samples(sampler, num_shots, seed=99, pauli_masks=masks) == 0 + + +def test_d5_compile_smoke(patch_d3: SurfacePatch) -> None: + del patch_d3 + patch_d5 = SurfacePatch.create(distance=5) + masks = sample_pauli_masks_from_guppy( + patch_d5, + num_rounds=3, + num_shots=2, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=11), + ) + + num_data = patch_d5.geometry.num_data + assert masks.shape == (2, num_pauli_sites(3, num_data)) + assert masks.dtype == np.uint8 + assert masks.min() >= 0 + assert masks.max() <= 3 diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py new file mode 100644 index 000000000..4fbecf65a --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -0,0 +1,519 @@ +from __future__ import annotations + +import numpy as np +import pytest +from pecos.qec.surface import ( + GuppyRngMaskConfig, + NoiseModel, + SurfacePatch, + TwirlConfig, + build_memory_circuit, + build_native_sampler, + decode_native_samples, + demask_pauli_frame_records, +) +from pecos.qec.surface._twirl_sites import ( + mask_col_for, + mask_col_for_gate_operand, + num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_active_gate_tag, + pauli_active_round_tag, + pauli_mask_gate_tag, + pauli_mask_round_tag, +) +from pecos.qec.surface.decode import ( + _extract_pauli_activations_from_results, + _extract_pauli_masks_from_results, + generate_circuit_level_dem_from_builder, +) + + +def test_extract_pauli_masks_packs_bits_in_row_major_site_qubit_order() -> None: + results = { + pauli_mask_round_tag(0): [[1, 0, 1, 1]], + pauli_mask_round_tag(1): [[0, 1, 0, 0]], + } + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=3, + num_data=2, + num_shots=1, + ) + + assert mask.dtype == np.uint8 + assert mask.tolist() == [[1, 3, 2, 0]] + assert mask[0, mask_col_for(0, 0, 2)] == 1 + assert mask[0, mask_col_for(0, 1, 2)] == 3 + assert mask[0, mask_col_for(1, 0, 2)] == 2 + assert mask[0, mask_col_for(1, 1, 2)] == 0 + + +def test_extract_scaled_round_twirl_requires_and_validates_activation_tags() -> None: + scaled = TwirlConfig(twirl_probability=0.5) + results = { + pauli_mask_round_tag(0): [[1, 0, 0, 0]], + pauli_active_round_tag(0): [[True, False]], + } + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + active = _extract_pauli_activations_from_results( + results, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + assert mask.tolist() == [[1, 0]] + assert active.tolist() == [[True, False]] + + with pytest.raises(ValueError, match="missing Pauli-activation result tag"): + _extract_pauli_masks_from_results( + {pauli_mask_round_tag(0): [[0, 0, 0, 0]]}, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + malformed = { + pauli_mask_round_tag(0): [[1, 0, 0, 0]], + pauli_active_round_tag(0): [[False, True]], + } + with pytest.raises(ValueError, match="inactive round site recorded a non-identity"): + _extract_pauli_masks_from_results( + malformed, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + +def test_extract_pauli_masks_rejects_missing_or_misshaped_tags() -> None: + with pytest.raises(ValueError, match="missing Pauli-mask result tag"): + _extract_pauli_masks_from_results({}, num_rounds=2, num_data=1, num_shots=1) + + with pytest.raises(ValueError, match=r"expected \(1, 4\)"): + _extract_pauli_masks_from_results( + {pauli_mask_round_tag(0): [[1, 0, 1]]}, + num_rounds=2, + num_data=2, + num_shots=1, + ) + + +def test_extract_gate_local_pauli_masks_packs_operand_order() -> None: + patch = SurfacePatch.create(distance=3) + results = { + pauli_mask_gate_tag(site): [[0, 0, 0, 0]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + } + results[pauli_mask_gate_tag(0)] = [[1, 0, 0, 1]] + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=1, + num_data=patch.geometry.num_data, + num_shots=1, + patch=patch, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + + assert mask.dtype == np.uint8 + assert mask.shape == ( + 1, + num_pauli_sites_for_schedule( + patch, + num_rounds=1, + basis="Z", + site_schedule="before_two_qubit_gate", + ), + ) + assert mask[0, mask_col_for_gate_operand(0, 0)] == 1 + assert mask[0, mask_col_for_gate_operand(0, 1)] == 2 + + +def test_extract_scaled_gate_local_twirl_validates_activation_tags() -> None: + patch = SurfacePatch.create(distance=3) + twirl = TwirlConfig(site_schedule="before_two_qubit_gate", twirl_probability=0.5) + results = { + pauli_mask_gate_tag(site): [[0, 0, 0, 0]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + } + results.update( + { + pauli_active_gate_tag(site): [[True, True]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + }, + ) + results[pauli_mask_gate_tag(0)] = [[1, 0, 0, 0]] + results[pauli_active_gate_tag(0)] = [[False, True]] + + with pytest.raises(ValueError, match="inactive gate-local site recorded a non-identity"): + _extract_pauli_masks_from_results( + results, + num_rounds=1, + num_data=patch.geometry.num_data, + num_shots=1, + patch=patch, + basis="Z", + twirl=twirl, + ) + + +def test_demask_helper_cancels_known_pauli_frame_xor() -> None: + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + masks = np.zeros((2, sampler.num_pauli_sites), dtype=np.uint8) + masks[0, 0] = 1 + masks[1, 0] = 3 + + det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(masks.astype(np.int64)) + physical_events = np.zeros((2, sampler.num_detectors), dtype=bool) + physical_obs = np.zeros((2, sampler.num_observables), dtype=bool) + physical_events[0, 0] = True + physical_obs[1, 0] = True + + raw_events = physical_events ^ np.asarray(det_xor, dtype=bool) + raw_obs = physical_obs ^ np.asarray(obs_xor, dtype=bool) + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + np.testing.assert_array_equal(events, physical_events) + np.testing.assert_array_equal(observables, physical_obs) + + +def test_native_sampler_accepts_harvested_uint8_pauli_masks() -> None: + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + assert sampler.num_pauli_sites > 0 + + masks = np.zeros((4, sampler.num_pauli_sites), dtype=np.uint8) + masks[:, 0] = [0, 1, 2, 3] + + det_events, obs_flips = sampler.sample(4, seed=123, pauli_masks=masks) + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + + assert decode_native_samples(sampler, 4, seed=123, pauli_masks=masks) == 0 + + +def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: + patch = SurfacePatch.create(distance=3) + raw = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + canonical = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(frame_output="canonical"), + ) + scaled = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(twirl_probability=0.5), + ) + + assert canonical.num_detectors == raw.num_detectors + assert canonical.num_observables == raw.num_observables + assert canonical.num_pauli_sites == raw.num_pauli_sites + assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup + assert canonical.dem_string == raw.dem_string + assert scaled.num_detectors == raw.num_detectors + assert scaled.num_observables == raw.num_observables + assert scaled.num_pauli_sites == raw.num_pauli_sites + assert scaled.pauli_frame_lookup is raw.pauli_frame_lookup + assert scaled.dem_string == raw.dem_string + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("scheme", "clifford"), + ("site_schedule", "per_two_qubit_gate"), + ("result_encoding", "bogus"), + ("frame_output", "physical"), + ("twirl_probability", -0.1), + ("twirl_probability", 1.1), + ], +) +def test_abstract_twirl_builders_reject_unsupported_config( + field: str, + value: object, +) -> None: + patch = SurfacePatch.create(distance=3) + kwargs = {field: value} + twirl = TwirlConfig(**kwargs) # type: ignore[arg-type] + + with pytest.raises(ValueError, match=field): + twirl.validate_runtime_supported() + + with pytest.raises(ValueError, match=field): + build_memory_circuit( + patch=patch, + rounds=2, + basis="Z", + twirl=twirl, + ) + + with pytest.raises(ValueError, match=field): + build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + + with pytest.raises(ValueError, match=field): + generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + + +def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p_idle_x_quadratic_sine_rate=0.03) + twirl = TwirlConfig() + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=twirl, + ) + assert "error(" in dem + + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=noise, + basis="Z", + twirl=twirl, + ) + assert sampler.num_pauli_sites == num_pauli_sites(2, patch.geometry.num_data) + + det_events, obs_flips = sampler.sample(2, seed=7) + assert det_events.shape == (2, sampler.num_detectors) + assert obs_flips.shape == (2, sampler.num_observables) + + +@pytest.mark.parametrize( + ("label", "noise"), + [ + ("depolarizing", NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001)), + ("uniform_idle", NoiseModel(p_idle=0.002)), + ("t1_t2", NoiseModel(t1=1000.0, t2=800.0)), + ("linear_idle", NoiseModel(p_idle_linear_rate=0.001)), + ("quadratic_idle", NoiseModel(p_idle_quadratic_rate=0.01)), + ("sine_law_idle", NoiseModel(p_idle_x_quadratic_sine_rate=0.03)), + ], +) +def test_twirling_does_not_change_canonical_dem( + label: str, + noise: NoiseModel, +) -> None: + del label + patch = SurfacePatch.create(distance=3) + + untwirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + ) + twirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=TwirlConfig(), + ) + + assert twirled == untwirled + + +def test_gate_local_twirling_does_not_change_canonical_dem() -> None: + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) + + untwirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + ) + twirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + + assert twirled == untwirled + + +def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match="one concrete mask realization") as exc_info: + build_memory_circuit( + patch=patch, + rounds=2, + basis="Z", + circuit_source="traced_qis", + twirl=TwirlConfig(), + ) + + msg = str(exc_info.value) + assert "one concrete mask realization" in msg + assert "result-id provenance" in msg + assert "circuit_source='abstract'" in msg + + +@pytest.mark.parametrize("distance", [3, 5]) +@pytest.mark.parametrize("num_rounds", [2, 3]) +def test_tracked_pauli_label_order_matches_mask_col_for( + distance: int, + num_rounds: int, +) -> None: + patch = SurfacePatch.create(distance=distance) + num_data = patch.geometry.num_data + tc = build_memory_circuit( + patch=patch, + rounds=num_rounds, + basis="Z", + twirl=TwirlConfig(), + ) + tracked = [a for a in tc.annotations() if a["kind"] == "tracked_pauli"] + + assert len(tracked) == 3 * num_pauli_sites(num_rounds, num_data) + for site in range(num_rounds - 1): + for q in range(num_data): + col = mask_col_for(site, q, num_data) + base = 3 * col + for offset, kind in enumerate(("X", "Y", "Z")): + assert tracked[base + offset]["label"] == f"twirl_s{site}_q{q}_{kind}" + + +def test_gate_local_tracked_pauli_label_order_matches_gate_operand_cols() -> None: + from pecos.qec.surface.schedule import compute_cnot_schedule + + patch = SurfacePatch.create(distance=3) + num_rounds = 2 + tc = build_memory_circuit( + patch=patch, + rounds=num_rounds, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + tracked = [a for a in tc.annotations() if a["kind"] == "tracked_pauli"] + expected_cols = num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis="Z", + site_schedule="before_two_qubit_gate", + ) + assert len(tracked) == 3 * expected_cols + + first_init_x_gate = next( + (stab_idx, data_idx) + for cx_round in compute_cnot_schedule(patch) + for stab_type, stab_idx, data_idx in cx_round + if stab_type == "X" + ) + stab_idx, data_idx = first_init_x_gate + first_control = patch.geometry.num_data + stab_idx + expected_first_labels = [ + *(f"twirl_g0o0_q{first_control}_{kind}" for kind in ("X", "Y", "Z")), + *(f"twirl_g0o1_q{data_idx}_{kind}" for kind in ("X", "Y", "Z")), + ] + assert [tracked[i]["label"] for i in range(6)] == expected_first_labels + + +def test_raw_twirled_guppy_trace_result_provenance_ignores_sideband_tags() -> None: + pytest.importorskip("guppylang") + pytest.importorskip("selene_sim") + + from pecos.guppy import get_num_qubits + from pecos.guppy.surface import generate_memory_experiment + from pecos.qec.surface.decode import ( + _index_surface_result_trace_ids, + trace_guppy_into_tick_circuit_with_result_traces, + ) + + patch = SurfacePatch.create(distance=3) + num_rounds = 2 + twirl = TwirlConfig(twirl_probability=0.5) + program = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=0), + ) + _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( + program, + get_num_qubits(patch=patch, twirl=twirl), + seed=0, + ) + + sideband_names = {trace.get("name") for trace in result_traces if isinstance(trace.get("name"), str)} + assert any(str(name).startswith("pauli_mask:") for name in sideband_names) + assert any(str(name).startswith("pauli_active:") for name in sideband_names) + assert "frame_mode:raw" in sideband_names + + scalar_trace_ids, array_trace_ids = _index_surface_result_trace_ids(result_traces) + indexed_names = set(scalar_trace_ids) | set(array_trace_ids) + assert any(name.startswith("sx") and ":meas:" in name for name in indexed_names) + assert "final" in indexed_names + assert not any(name.startswith(("pauli_mask:", "pauli_active:", "frame_mode:", "raw:")) for name in indexed_names) diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index 0cdcd1486..e50730eb2 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -187,6 +187,131 @@ def test_decoder_types(self) -> None: d3 = SurfaceDecoder(patch, decoder_type="bp_osd", noise=noise) assert d3.decoder_type.value == "bp_osd" + # Explicit PyMatching DEM-correlation modes + d4 = SurfaceDecoder(patch, decoder_type="pymatching_correlated", noise=noise) + assert d4.decoder_type.value == "pymatching_correlated" + + d5 = SurfaceDecoder(patch, decoder_type="pymatching_uncorrelated", noise=noise) + assert d5.decoder_type.value == "pymatching_uncorrelated" + + def test_circuit_level_pymatching_uses_correlations_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The production circuit-level PyMatching path should consume DEM correlation metadata.""" + import pecos.decoders as decoders_module + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **_kwargs: object) -> str: + return "error(0.01) D0 ^ D1 L0\n" + + class DummyPyMatchingDecoder: + @classmethod + def from_dem_with_correlations(cls, dem: str, *, enable_correlations: bool) -> object: + seen["method"] = "from_dem_with_correlations" + seen["dem"] = dem + seen["enable_correlations"] = enable_correlations + return object() + + @classmethod + def from_dem(cls, _dem: str) -> object: + raise AssertionError + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + monkeypatch.setattr(decoders_module, "PyMatchingDecoder", DummyPyMatchingDecoder) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + decoder_type="pymatching", + circuit_level_dem_mode="native_decomposed", + ) + + assert decoder._get_z_decoder() is not None + assert seen == { + "method": "from_dem_with_correlations", + "dem": "error(0.01) D0 ^ D1 L0\n", + "enable_correlations": True, + } + + def test_circuit_level_uncorrelated_pymatching_uses_plain_dem(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The explicit uncorrelated option remains available for A/B diagnostics.""" + import pecos.decoders as decoders_module + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **_kwargs: object) -> str: + return "error(0.01) D0 ^ D1 L0\n" + + class DummyPyMatchingDecoder: + @classmethod + def from_dem_with_correlations(cls, _dem: str, **_kwargs: object) -> object: + raise AssertionError + + @classmethod + def from_dem(cls, dem: str) -> object: + seen["method"] = "from_dem" + seen["dem"] = dem + return object() + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + monkeypatch.setattr(decoders_module, "PyMatchingDecoder", DummyPyMatchingDecoder) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + decoder_type="pymatching_uncorrelated", + circuit_level_dem_mode="native_decomposed", + ) + + assert decoder._get_z_decoder() is not None + assert seen == { + "method": "from_dem", + "dem": "error(0.01) D0 ^ D1 L0\n", + } + + def test_correlated_pymatching_requires_circuit_level_dem(self) -> None: + """The correlated option needs DEM metadata and should fail without it.""" + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + decoder = SurfaceDecoder( + patch, + decoder_type="pymatching_correlated", + noise=noise, + use_circuit_level_dem=False, + ) + + with pytest.raises(ValueError, match="requires circuit-level DEM"): + decoder._get_z_decoder() + + def test_correlated_pymatching_requires_decomposed_dem_mode(self) -> None: + """The explicit correlated option needs decomposed DEM metadata.""" + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + decoder = SurfaceDecoder( + patch, + decoder_type="pymatching_correlated", + noise=noise, + circuit_level_dem_mode="native_full", + ) + + with pytest.raises(ValueError, match="requires a decomposed"): + decoder._get_z_decoder() + + def test_recommended_memory_workflow_uses_terminal_graphlike_for_pymatching(self) -> None: + """The high-level memory helper should use the best measured graphlike projection.""" + import pecos.qec.surface.decode as decode_module + + for decoder_type in ["pymatching", "pymatching_correlated", "pymatching_uncorrelated"]: + assert decode_module._recommended_graphlike_decomposition_for_decoder(decoder_type) == "terminal_graphlike" + assert decode_module._recommended_graphlike_decomposition_for_decoder("tesseract") == "source_graphlike" + def test_get_dem(self) -> None: """Test DEM generation via decoder.""" patch = SurfacePatch.create(distance=3) @@ -223,10 +348,10 @@ def test_get_dem_caches_circuit_level_dem(self, monkeypatch: pytest.MonkeyPatch) real_generate = decode_module.generate_circuit_level_dem_from_builder calls = 0 - def wrapped_generate(*args: object, **kwargs: object) -> str: + def wrapped_generate(*_args: object, **kwargs: object) -> str: nonlocal calls calls += 1 - return real_generate(*args, **kwargs) + return real_generate(*_args, **kwargs) monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) @@ -236,6 +361,63 @@ def wrapped_generate(*args: object, **kwargs: object) -> str: assert dem_1 == dem_2 assert calls == 1 + def test_get_dem_passes_interaction_basis_to_native_builder(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Decoder DEM generation should use the requested interaction basis.""" + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **kwargs: object) -> str: + seen["interaction_basis"] = kwargs.get("interaction_basis") + return "error(0.01) D0\n" + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + circuit_level_dem_mode="native_decomposed", + interaction_basis="SZZ", + ) + + assert decoder.interaction_basis == "szz" + assert decoder.get_dem("Z", circuit_level=True) == "error(0.01) D0\n" + assert seen["interaction_basis"] == "szz" + + def test_get_dem_passes_terminal_graphlike_mode_to_native_builder( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The terminal graphlike mode should select the terminal DEM projection.""" + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **kwargs: object) -> str: + seen["decompose_errors"] = kwargs.get("decompose_errors") + seen["dem_decomposition"] = kwargs.get("dem_decomposition") + return "error(0.01) D0\n" + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + circuit_level_dem_mode="native_terminal_graphlike", + ) + + assert decoder.get_dem("Z", circuit_level=True) == "error(0.01) D0\n" + assert seen == { + "decompose_errors": True, + "dem_decomposition": "terminal_graphlike", + } + def test_decode_trivial_syndrome_z(self) -> None: """Decode trivial Z syndrome (no errors).""" patch = SurfacePatch.create(distance=3) @@ -249,8 +431,14 @@ def test_decode_trivial_syndrome_z(self) -> None: synx_list = [np.zeros(num_x_stab, dtype=np.uint8)] synz_list = [np.zeros(num_z_stab, dtype=np.uint8)] final = np.zeros(patch.num_data, dtype=np.uint8) + init_synx = np.zeros(num_x_stab, dtype=np.uint8) - is_error, _result = decoder.decode_memory_z(synx_list, synz_list, final) + is_error, _result = decoder.decode_memory_z( + synx_list, + synz_list, + final, + init_synx=init_synx, + ) # No errors should be detected assert not is_error @@ -267,12 +455,80 @@ def test_decode_trivial_syndrome_x(self) -> None: synx_list = [np.zeros(num_x_stab, dtype=np.uint8)] synz_list = [np.zeros(num_z_stab, dtype=np.uint8)] final = np.zeros(patch.num_data, dtype=np.uint8) + init_synz = np.zeros(num_z_stab, dtype=np.uint8) - is_error, _result = decoder.decode_memory_x(synx_list, synz_list, final) + is_error, _result = decoder.decode_memory_x( + synx_list, + synz_list, + final, + init_synz=init_synz, + ) # No errors should be detected assert not is_error + def test_dem_detection_events_require_prep_baseline(self) -> None: + """Circuit-level DEM event construction should fail loudly without prep baselines.""" + patch = SurfacePatch.create(distance=3) + decoder = SurfaceDecoder(patch, num_rounds=2) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + synx_list = [np.zeros(num_x_stab, dtype=np.uint8) for _ in range(2)] + synz_list = [np.zeros(num_z_stab, dtype=np.uint8) for _ in range(2)] + final = np.zeros(patch.num_data, dtype=np.uint8) + + with pytest.raises(ValueError, match="requires init_synx"): + decoder._compute_dem_detection_events_z(synx_list, synz_list, final) + with pytest.raises(ValueError, match="requires init_synz"): + decoder._compute_dem_detection_events_x(synx_list, synz_list, final) + + def test_dem_detection_events_count_only_syndrome_rounds_as_duration(self) -> None: + """Prep baselines and destructive readout should not add counted syndrome rounds.""" + patch = SurfacePatch.create(distance=3) + rounds = 2 + decoder = SurfaceDecoder(patch, num_rounds=rounds) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + synx_list = [np.zeros(num_x_stab, dtype=np.uint8) for _ in range(rounds)] + synz_list = [np.zeros(num_z_stab, dtype=np.uint8) for _ in range(rounds)] + final = np.zeros(patch.num_data, dtype=np.uint8) + + z_events = decoder._compute_dem_detection_events_z( + synx_list, + synz_list, + final, + init_synx=np.zeros(num_x_stab, dtype=np.uint8), + ) + x_events = decoder._compute_dem_detection_events_x( + synx_list, + synz_list, + final, + init_synz=np.zeros(num_z_stab, dtype=np.uint8), + ) + + assert z_events.shape == (rounds * (num_x_stab + num_z_stab) + num_z_stab,) + assert x_events.shape == (rounds * (num_x_stab + num_z_stab) + num_x_stab,) + + def test_zero_round_dem_events_use_readout_against_prep_boundary(self) -> None: + """r=0 still has terminal detectors from final readout versus prep signs.""" + patch = SurfacePatch.create(distance=3) + decoder = SurfaceDecoder(patch, num_rounds=0) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + + z_final = np.zeros(patch.num_data, dtype=np.uint8) + z_final[patch.geometry.z_stabilizers[0].data_qubits[0]] = 1 + x_final = np.zeros(patch.num_data, dtype=np.uint8) + x_final[patch.geometry.x_stabilizers[0].data_qubits[0]] = 1 + + z_events = decoder._compute_dem_detection_events_z([], [], z_final) + x_events = decoder._compute_dem_detection_events_x([], [], x_final) + + assert z_events.shape == (num_z_stab,) + assert x_events.shape == (num_x_stab,) + assert z_events[0] == 1 + assert x_events[0] == 1 + class TestDemGeneration: """Tests for DEM generation functions.""" @@ -369,7 +625,11 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: DEM built fresh from the corresponding TickCircuit, for both the ``abstract`` and ``traced_qis`` sources -- pinning that caching is sound for constrained budgets, not just unconstrained ones.""" - from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit, generate_tick_circuit_from_patch + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit, + generate_tick_circuit_from_patch, + normalize_traced_qis_tick_circuit, + ) from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, generate_circuit_level_dem_from_builder, @@ -399,6 +659,10 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: ancilla_budget=2, circuit_source="traced_qis", ) + # The native topology path normalizes traced-QIS circuits (Clifford-rotation + # lowering + single-qubit Clifford-chain simplification) before DEM + # construction; the fresh comparison must apply the same normalization. + normalize_traced_qis_tick_circuit(traced_tc, context="constrained budget cache test") cached_traced = generate_circuit_level_dem_from_builder( patch, num_rounds=2, @@ -473,6 +737,45 @@ def test_constrained_budget_sampler_builds_for_all_models(self) -> None: ) assert sampler.num_detectors == expected_detectors + def test_constrained_budget_dem_remains_strictly_decodable(self) -> None: + """Constrained ancilla reuse should not produce ungraphlike DEM artifacts. + + This pins a regression where DAG fault propagation grouped measurements + by physical ancilla slot. That shortcut is invalid when the same slot is + reused for different stabilizers and produced high-degree mechanisms + that strict PyMatching could not parse. + """ + from pecos.qec import ParsedDem + + patch = SurfacePatch.create(distance=5) + params = { + "p1": 0.0, + "p2": 0.001, + "p_meas": 0.0, + "p_prep": 0.0, + "decompose_errors": True, + } + + for basis in ("X", "Z"): + tc = generate_tick_circuit_from_patch( + patch, + num_rounds=5, + basis=basis, + ancilla_budget=8, + ) + dem = generate_dem_from_tick_circuit(tc, **params) + sampler = ParsedDem.from_string(dem).to_dem_sampler() + + assert ( + sampler.sample_decode_count( + dem, + 16, + decoder_type="pymatching", + seed=1234, + ) + >= 0 + ) + def test_traced_qis_traces_the_given_patch_not_its_distance(self) -> None: """A non-rotated patch must be traced from its OWN Guppy program, not the default rotated patch of the same distance. Before the patch- @@ -492,6 +795,30 @@ def traced_dem(*, rotated: bool) -> str: assert traced_dem(rotated=True) != traced_dem(rotated=False) + def test_traced_qis_native_topology_lowers_clifford_rotations(self) -> None: + """Surface native topology should match from_guppy's traced-QIS normalization.""" + from pecos.qec.surface.decode import _surface_native_topology, _surface_patch_cache_key + + _require_selene_runtime() + + patch = SurfacePatch.create(distance=3) + topology = _surface_native_topology( + _surface_patch_cache_key(patch), + 2, + "Z", + None, + "traced_qis", + False, + ) + gate_names = { + topology.dag_circuit.gate(node).gate_type.name + for node in topology.dag_circuit.nodes() + if topology.dag_circuit.gate(node) is not None + } + + assert "RZZ" not in gate_names + assert "SZZ" in gate_names + def test_guppy_module_cache_keys_on_full_patch_identity(self) -> None: """Rotated and non-rotated patches of the same dx/dz/budget must NOT share a cached Guppy module (they generate different circuits).""" diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index f5f7fa606..7e01b5d1b 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -15,6 +15,7 @@ SurfacePatch, classify_stabilizer_boundary, describe_surface_memory_experiment, + extract_detection_events_and_observables, generate_tick_circuit_from_patch, get_detector_descriptors_from_tick_circuit, get_measurement_order_from_tick_circuit, @@ -25,11 +26,39 @@ get_stabilizer_schedule_metadata, get_stabilizer_touch_label, ) +from pecos.qec.surface.circuit_builder import _metadata_record_offsets if TYPE_CHECKING: from pecos.qec.surface import SurfacePatchDescriptor +class _MetadataOnlyTickCircuit: + def __init__(self, metadata: dict[str, str]) -> None: + self._metadata = metadata + + def get_meta(self, key: str) -> str | None: + return self._metadata.get(key) + + +def test_metadata_record_offsets_require_records_or_meas_ids() -> None: + """Local metadata consumers should not silently ignore malformed entries.""" + assert _metadata_record_offsets({"records": []}, 3) == [] + assert _metadata_record_offsets({"meas_ids": [1, 2]}, 5) == [-4, -3] + + with pytest.raises(ValueError, match=r"records.*meas_ids"): + _metadata_record_offsets({"id": 0}, 3) + + tick_circuit = _MetadataOnlyTickCircuit( + { + "detectors": json.dumps([{"id": 0, "coords": [0, 0, 0]}]), + "observables": "[]", + "num_measurements": "1", + }, + ) + with pytest.raises(ValueError, match=r"records.*meas_ids"): + extract_detection_events_and_observables(tick_circuit, [[0]]) + + def test_surface_schedule_helpers_expose_region_and_touch_labels() -> None: """Surface metadata helpers should expose stable boundary and touch labels.""" patch = SurfacePatch.create(distance=3) @@ -178,6 +207,38 @@ def test_tick_circuit_exposes_detector_descriptors() -> None: assert final_x["coords"] == [0, 0, 2] +@pytest.mark.parametrize( + ("basis", "baseline_kind", "detector_y"), + [("Z", "X", 0), ("X", "Z", 1)], +) +def test_tick_circuit_uses_explicit_prep_syndrome_baseline( + basis: str, + baseline_kind: str, + detector_y: int, +) -> None: + """Round-0 random-sign detectors should compare against prep syndrome measurements.""" + patch = SurfacePatch.create(distance=3) + tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis=basis) + detectors = json.loads(tc.get_meta("detectors") or "[]") + num_measurements = int(tc.get_meta("num_measurements") or "0") + init_count = len(patch.x_stabilizers if baseline_kind == "X" else patch.z_stabilizers) + + assert num_measurements == init_count + 2 * patch.num_ancilla + patch.num_data + + init_tick_rounds = [ + tc.get_tick_meta(tick_index, "syndrome_round") + for tick_index in range(tc.num_ticks()) + if tc.get_tick_meta(tick_index, "syndrome_round") == -1 + ] + assert init_tick_rounds + + first_random_detector = next(det for det in detectors if det["coords"][1] == detector_y and det["coords"][2] == 0) + assert len(first_random_detector["records"]) == 2 + record_indices = [num_measurements + int(record) for record in first_random_detector["records"]] + assert record_indices[0] >= init_count + assert record_indices[1] < init_count + + def test_tick_circuit_exposes_observable_descriptors() -> None: """Tick circuits should publish observable descriptors derived from logical metadata.""" patch = SurfacePatch.create(distance=3) @@ -199,19 +260,23 @@ def test_tick_circuit_exposes_observable_descriptors() -> None: def test_tick_circuit_exposes_measurement_order() -> None: - """Tick circuits should expose measurement order matching their MZ gates.""" + """Tick circuits should expose measurement order matching measurement gates.""" patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") observed = get_measurement_order_from_tick_circuit(tc) expected: list[int] = [] + has_measure_free = False for tick_index in range(tc.num_ticks()): tick = tc.get_tick(tick_index) if tick is None: continue for gate in tick.gate_batches(): - if "MZ" not in str(gate.gate_type): + gate_type = str(gate.gate_type) + if "MeasureFree" in gate_type: + has_measure_free = True + if "MZ" not in gate_type and "MeasureFree" not in gate_type: continue for qubit in gate.qubits: if hasattr(qubit, "index"): @@ -219,6 +284,7 @@ def test_tick_circuit_exposes_measurement_order() -> None: else: expected.append(int(qubit)) + assert has_measure_free assert observed == expected assert len(observed) == int(tc.get_meta("num_measurements") or "0") @@ -238,9 +304,11 @@ def test_tick_circuit_respects_ancilla_budget_in_measurement_order() -> None: batched_order = get_measurement_order_from_tick_circuit(batched_tc) num_ancilla = patch.geometry.num_ancilla - full_ancilla_measures = full_order[:num_ancilla] - batched_ancilla_measures = batched_order[:num_ancilla] + init_count = len(patch.x_stabilizers) + full_ancilla_measures = full_order[init_count : init_count + num_ancilla] + batched_ancilla_measures = batched_order[init_count : init_count + num_ancilla] + assert len(set(full_order[:init_count])) == init_count assert len(set(full_ancilla_measures)) == num_ancilla assert len(set(batched_ancilla_measures)) == 2 assert max(batched_ancilla_measures) == patch.num_data + 1 diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py new file mode 100644 index 000000000..e96fcc4b1 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -0,0 +1,1072 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +from __future__ import annotations + +import json +import re + +import numpy as np +import pecos as pc +import pytest +import stim +from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig +from pecos.qec.surface.circuit_builder import ( + OpType, + SurfaceCircuitStep, + SzzTouchSign, + _analyze_szz_forward_flow, + _default_szz_residual_plan, + _default_szz_sign_vector, + _propagate_compensated_szz_frame_bits, + _propagate_sxx_frame_bits, + _propagate_szz_frame_bits, + _szz_residual_class, + _validate_szz_sign_vector, + build_surface_code_circuit, + generate_dag_circuit_from_patch, + generate_dem_from_tick_circuit, + generate_dem_from_tick_circuit_via_stim, + generate_stim_from_patch, + generate_tick_circuit_from_patch, +) +from pecos.qec.surface.decode import ( + _dem_string_from_cached_surface_topology, + _surface_native_topology, + _surface_patch_cache_key, + build_memory_circuit, + build_native_sampler, + generate_circuit_level_dem_from_builder, +) +from pecos.quantum import PHYSICAL_DURATION_META_KEY + + +def _to_numpy_complex(matrix: object) -> np.ndarray: + arr = np.asarray(matrix) + if arr.ndim >= 1 and arr.shape[-1:] == (2,) and not np.issubdtype(arr.dtype, np.complexfloating): + return arr[..., 0].astype(float) + 1j * arr[..., 1].astype(float) + return np.asarray(matrix, dtype=complex) + + +def _pauli_matrix(pauli: object) -> np.ndarray: + return _to_numpy_complex(pauli.to_matrix()) + + +def _kron(left: np.ndarray, right: np.ndarray) -> np.ndarray: + return _to_numpy_complex(pc.kron(pc.array(left), pc.array(right))) + + +I2 = _pauli_matrix(pc.PauliString.I()) +PAULI_X = _pauli_matrix(pc.X(0)) +PAULI_Z = _pauli_matrix(pc.Z(0)) +H = (PAULI_X + PAULI_Z) / np.sqrt(2.0) +SZ = (I2 + PAULI_Z) / 2 + 1j * (I2 - PAULI_Z) / 2 +SZDG = SZ.conj().T +SX = np.cos(np.pi / 4) * I2 - 1j * np.sin(np.pi / 4) * PAULI_X +I4 = _kron(I2, I2) +ZI = _kron(PAULI_Z, I2) +IZ = _kron(I2, PAULI_Z) +ZZ = _pauli_matrix(pc.Z(0) & pc.Z(1)) +CZ = (I4 + ZI + IZ - ZZ) / 2 +SZZ = np.cos(np.pi / 4) * I4 - 1j * np.sin(np.pi / 4) * ZZ +SZZDG = SZZ.conj().T +CX = _kron(I2, H) @ CZ @ _kron(I2, H) +SXX = _kron(H, H) @ SZZ @ _kron(H, H) + + +def _raw_dem_errors(dem_text: str) -> dict[str, float]: + errors: dict[str, float] = {} + for line in dem_text.splitlines(): + match = re.match(r"error\(([^)]+)\)\s*(.*)", line.strip()) + if match: + target = match.group(2).strip() + errors[target] = errors.get(target, 0.0) + float(match.group(1)) + return errors + + +def _equiv_up_to_global_phase(left: np.ndarray, right: np.ndarray, *, atol: float = 1e-10) -> bool: + flat_right = right.ravel() + flat_left = left.ravel() + nonzero = np.flatnonzero(np.abs(flat_right) > atol) + if nonzero.size == 0: + return bool(np.allclose(left, right, atol=atol)) + ratio = flat_left[nonzero[0]] / flat_right[nonzero[0]] + return bool(np.allclose(flat_left, ratio * flat_right, atol=atol)) + + +def _pauli_from_bits(x_bit: bool, z_bit: bool) -> np.ndarray: + op = I2 + if x_bit: + op = PAULI_X @ op + if z_bit: + op = PAULI_Z @ op + return op + + +def _bits_from_pauli(op: np.ndarray) -> tuple[bool, bool]: + for x_bit in (False, True): + for z_bit in (False, True): + if _equiv_up_to_global_phase(op, _pauli_from_bits(x_bit, z_bit)): + return x_bit, z_bit + msg = f"not a Pauli up to phase:\n{op}" + raise AssertionError(msg) + + +def _matrix_frame_update( + unitary: np.ndarray, + x_a: bool, + z_a: bool, + x_b: bool, + z_b: bool, +) -> tuple[bool, bool, bool, bool]: + before = _kron(_pauli_from_bits(x_a, z_a), _pauli_from_bits(x_b, z_b)) + after = unitary @ before @ unitary.conj().T + basis = (I2, PAULI_X, PAULI_Z, PAULI_X @ PAULI_Z) + for left in basis: + for right in basis: + candidate = _kron(left, right) + if _equiv_up_to_global_phase(after, candidate): + ax, az = _bits_from_pauli(left) + bx, bz = _bits_from_pauli(right) + return ax, az, bx, bz + msg = f"not a tensor-product Pauli up to phase:\n{after}" + raise AssertionError(msg) + + +def _record_parity(raw_records: np.ndarray, records: list[int]) -> np.ndarray: + num_measurements = raw_records.shape[1] + if not records: + return np.zeros(raw_records.shape[0], dtype=bool) + indices = [num_measurements + int(record) for record in records] + return np.bitwise_xor.reduce(raw_records[:, indices], axis=1) + + +def _assert_noiseless_record_metadata_is_zero(stim_text: str, tick_circuit: object) -> None: + circuit = stim.Circuit(stim_text) + circuit.detector_error_model() + + raw_records = circuit.compile_sampler(seed=20260612).sample(shots=32) + detectors = json.loads(tick_circuit.get_meta("detectors") or "[]") + observables = json.loads(tick_circuit.get_meta("observables") or "[]") + + for detector in detectors: + assert not np.any(_record_parity(raw_records, detector["records"])) + for observable in observables: + assert not np.any(_record_parity(raw_records, observable["records"])) + + det_samples, obs_samples = circuit.compile_detector_sampler(seed=20260612).sample( + shots=32, + separate_observables=True, + ) + assert det_samples.shape == (32, circuit.num_detectors) + assert obs_samples.shape == (32, circuit.num_observables) + assert not np.any(det_samples) + assert not np.any(obs_samples) + + +def _gate_labels_for_tick(tick_circuit: object, tick_index: int) -> list[str | None]: + return [ + tick_circuit.get_gate_meta(tick_index, gate_index, "label") + for gate_index, _gate in enumerate(tick_circuit.get_tick(tick_index).gate_batches()) + ] + + +def _retag_virtual_prefix_duration(tick_circuit: object, duration: float) -> int: + count = 0 + for tick_index in range(tick_circuit.num_ticks()): + for gate_index, _gate in enumerate(tick_circuit.get_tick(tick_index).gate_batches()): + label = tick_circuit.get_gate_meta(tick_index, gate_index, "label") + if label and label.startswith("szz_virtual_prefix:"): + tick_circuit.set_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY, duration) + count += 1 + return count + + +def test_szz_unitary_identities() -> None: + assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) + assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) + assert _equiv_up_to_global_phase( + _kron(I2, H) @ SZZ @ _kron(I2, H), + CX @ _kron(SZ, SX), + ) + + +@pytest.mark.parametrize("x_a", [False, True]) +@pytest.mark.parametrize("z_a", [False, True]) +@pytest.mark.parametrize("x_b", [False, True]) +@pytest.mark.parametrize("z_b", [False, True]) +def test_szz_frame_rules_match_matrix_oracle( + x_a: bool, + z_a: bool, + x_b: bool, + z_b: bool, +) -> None: + assert _propagate_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SZZ, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SZZDG, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_compensated_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + CZ, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_sxx_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SXX, + x_a, + z_a, + x_b, + z_b, + ) + + +def test_szz_residual_classifier_and_default_plan() -> None: + patch = SurfacePatch.create(distance=3) + plan = _default_szz_residual_plan(patch) + + assert _szz_residual_class(0) == "identity" + assert _szz_residual_class(4) == "identity" + assert _szz_residual_class(2) == "pauli" + assert _szz_residual_class(-2) == "pauli" + assert _szz_residual_class(1) == "odd" + assert _szz_residual_class(-1) == "odd" + + assert {entry.sign for entry in plan.signs} == {-1, 1} + assert {entry.gate for entry in plan.boundary_compensations} == { + "SX", + "SXDG", + "SZ", + "SZDG", + } + assert {entry.pauli for entry in plan.class2_residuals} == {"X", "Z"} + + +def test_szz_bad_sign_vector_rejected_loudly() -> None: + patch = SurfacePatch.create(distance=3) + signs = list(_default_szz_sign_vector(patch)) + bad_index = next(i for i, entry in enumerate(signs) if entry.sign == -1) + bad_entry = signs[bad_index] + signs[bad_index] = SzzTouchSign( + bad_entry.stabilizer_type, + bad_entry.stabilizer_index, + bad_entry.data_qubit, + 1, + ) + + with pytest.raises(ValueError, match="ancilla residual is pauli"): + _validate_szz_sign_vector(patch, tuple(signs)) + + +def test_szz_builder_emits_szz_template_and_rejects_stage_later_features() -> None: + patch = SurfacePatch.create(distance=3) + + cx_ops, _ = build_surface_code_circuit(patch, num_rounds=1, interaction_basis="cx") + assert any(op.op_type == OpType.CX for op in cx_ops) + assert not any(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in cx_ops) + + szz_ops, _ = build_surface_code_circuit(patch, num_rounds=1, interaction_basis="szz") + op_types = {op.op_type for op in szz_ops} + assert OpType.CX not in op_types + assert {OpType.SZZ, OpType.SZZDG, OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} <= op_types + szz_gate_count = sum(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in szz_ops) + data_compensation_count = sum( + op.label.startswith("szz_touch_comp:") and op.op_type in {OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} + for op in szz_ops + ) + assert data_compensation_count == szz_gate_count + + constrained_szz_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + ancilla_budget=1, + interaction_basis="szz", + ) + constrained_szz_gate_count = sum(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in constrained_szz_ops) + constrained_compensation_count = sum( + op.label.startswith("szz_touch_comp:") and op.op_type in {OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} + for op in constrained_szz_ops + ) + assert constrained_szz_gate_count == szz_gate_count + assert constrained_compensation_count == constrained_szz_gate_count + + with pytest.raises(ValueError, match="twirl integration is staged later"): + build_surface_code_circuit( + patch, + num_rounds=1, + twirl=TwirlConfig(), + interaction_basis="szz", + ) + + +def test_szz_tick_circuit_uses_named_szz_gates() -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + add_detectors=False, + interaction_basis="szz", + ) + + gate_names = { + gate.gate_type.name + for tick_index in range(tick_circuit.num_ticks()) + for gate in tick_circuit.get_tick(tick_index).gate_batches() + } + assert "CX" not in gate_names + assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names + + +def test_szz_forward_flow_merges_h_sandwich_before_host() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.abstract_single_qubit_ops == 2 + assert summary.physical_prefix_pulses == 0 + assert summary.free_standing_single_qubit_ops == 0 + assert summary.pulses == () + + +def test_szz_forward_flow_carries_virtual_z_to_measurement() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.SZ, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.physical_prefix_pulses == 0 + assert summary.virtual_z_two_qubit_carries == 1 + assert summary.virtual_z_measure_discards == 1 + assert [event.kind for event in summary.pulses] == [ + "virtual_z_two_qubit_carry", + "virtual_z_measure_discard", + ] + + +def test_szz_forward_flow_counts_physical_prefixes_at_hosts() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.H, [1]), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.physical_prefix_pulses == 2 + assert summary.two_qubit_prefix_pulses == 1 + assert summary.measurement_prefix_pulses == 1 + assert [event.kind for event in summary.pulses] == [ + "physical_two_qubit_prefix", + "physical_measurement_prefix", + ] + + +@pytest.mark.parametrize( + ("basis", "expected"), + [ + ( + "Z", + { + "abstract_single_qubit_ops": 108, + "physical_prefix_pulses": 54, + "two_qubit_prefix_pulses": 38, + "measurement_prefix_pulses": 16, + "virtual_z_two_qubit_carries": 10, + "virtual_z_measure_discards": 5, + }, + ), + ( + "X", + { + "abstract_single_qubit_ops": 102, + "physical_prefix_pulses": 54, + "two_qubit_prefix_pulses": 37, + "measurement_prefix_pulses": 17, + "virtual_z_two_qubit_carries": 10, + "virtual_z_measure_discards": 3, + }, + ), + ], +) +def test_szz_forward_flow_surface_pulse_count_snapshot(basis: str, expected: dict[str, int]) -> None: + patch = SurfacePatch.create(distance=3) + ops, _ = build_surface_code_circuit(patch, num_rounds=1, basis=basis, interaction_basis="szz") + + summary = _analyze_szz_forward_flow(ops) + + assert summary.two_qubit_gates == 36 + assert summary.measurements == 21 + assert summary.prep_events == 21 + assert summary.free_standing_single_qubit_ops == 0 + for field, value in expected.items(): + assert getattr(summary, field) == value + assert summary.physical_prefix_pulses < summary.abstract_single_qubit_ops + + +def test_szz_direct_renderers_accept_interaction_basis() -> None: + patch = SurfacePatch.create(distance=3) + + stim_text = generate_stim_from_patch( + patch, + num_rounds=1, + interaction_basis="szz", + add_detectors=False, + ) + assert "CX" not in stim_text + assert "SQRT_X" in stim_text + assert "S_DAG" in stim_text + assert "SQRT_ZZ" in stim_text + assert "SQRT_ZZ_DAG" in stim_text + + dag_circuit = generate_dag_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") + gate_names = {dag_circuit.gate(node).gate_type.name for node in dag_circuit.nodes()} + assert "CX" not in gate_names + assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names + + +def test_szz_detector_paths_accept_abstract_and_traced_qis_basis() -> None: + patch = SurfacePatch.create(distance=3) + + stim_text = generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") + assert "DETECTOR" in stim_text + + tick_circuit = generate_tick_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + memory_circuit = build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") + assert int(memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) + + traced_memory_circuit = build_memory_circuit( + patch=patch, + rounds=1, + circuit_source="traced_qis", + interaction_basis="szz", + ) + assert traced_memory_circuit.get_meta("circuit_source") == "traced_qis" + assert int(traced_memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) + + +def test_szz_runtime_barriers_allow_strict_traced_hosted_order() -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = build_memory_circuit( + patch=patch, + rounds=1, + circuit_source="traced_qis", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + require_hosted_operation_order=True, + ) + assert tick_circuit.get_meta("circuit_source") == "traced_qis" + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0), + circuit_source="traced_qis", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + require_hosted_operation_order=True, + ) + assert stim.DetectorErrorModel(dem).num_detectors == int(tick_circuit.get_meta("num_detectors")) + + +@pytest.mark.parametrize("distance", [3, 5]) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> None: + patch = SurfacePatch.create(distance=distance) + + cx_text = generate_stim_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="cx") + szz_text = generate_stim_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="szz") + cx_tick = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="cx") + szz_tick = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="szz") + + cx_circuit = stim.Circuit(cx_text) + szz_circuit = stim.Circuit(szz_text) + assert cx_circuit.num_measurements == szz_circuit.num_measurements + assert cx_circuit.num_detectors == szz_circuit.num_detectors + assert cx_circuit.num_observables == szz_circuit.num_observables + assert cx_tick.get_meta("detectors") == szz_tick.get_meta("detectors") + assert cx_tick.get_meta("observables") == szz_tick.get_meta("observables") + + _assert_noiseless_record_metadata_is_zero(cx_text, cx_tick) + _assert_noiseless_record_metadata_is_zero(szz_text, szz_tick) + + +def test_szz_native_dem_path_uses_interaction_basis() -> None: + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}, p_meas=0.001, p_prep=0.001) + + cx_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + interaction_basis="cx", + ) + szz_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + interaction_basis="szz", + ) + + assert cx_dem != szz_dem + assert stim.DetectorErrorModel(szz_dem).num_detectors > 0 + + +def test_szz_native_dem_respects_gate_specific_p2_overrides() -> None: + patch = SurfacePatch.create(distance=3) + + inherited_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + ) + no_szz_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.01, p2_szz=0.0, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + ) + no_szzdg_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.01, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + ) + override_only_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel( + p1=0.0, + p2=0.0, + p2_szz=0.01, + p2_szzdg=0.01, + p2_weights={"ZI": 1.0}, + ), + interaction_basis="szz", + ) + + assert no_szz_dem != inherited_dem + assert no_szzdg_dem != inherited_dem + assert "error(" in override_only_dem + + +def test_szz_native_influence_sampler_respects_override_only_p2() -> None: + patch = SurfacePatch.create(distance=3) + + zero_sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.0, p2_szz=0.0, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + sampling_model="influence_dem", + ) + active_sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.0, p2_szz=0.01, p2_szzdg=0.01, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + sampling_model="influence_dem", + ) + + assert "mechanisms=0" in repr(zero_sampler.sampler) + assert "mechanisms=0" not in repr(active_sampler.sampler) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_prefix_lowering_preserves_p2_influence_dem(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + patch_key = _surface_patch_cache_key(patch) + noise = NoiseModel(p1=0.0, p2=0.01, p_meas=0.0, p_prep=0.0) + + plain = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + False, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + lowered = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + False, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + + assert _dem_string_from_cached_surface_topology( + lowered, + noise, + decompose_errors=False, + ) == _dem_string_from_cached_surface_topology( + plain, + noise, + decompose_errors=False, + ) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_lowered_native_dem_matches_stim_for_prefix_noise(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + p2 = 0.006 + noise_args = { + "p1": p2 / 30, + "p2": p2, + "p_meas": p2 / 3, + "p_prep": p2 / 3, + } + + native_errors = _raw_dem_errors( + generate_dem_from_tick_circuit( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} lowered SZZ DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_abstract_p2_only_raw_dem_matches_cx(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + noise_args = { + "p1": 0.0, + "p2": 0.006, + "p_meas": 0.0, + "p_prep": 0.0, + } + cx_tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="cx", + ) + szz_tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + + assert generate_dem_from_tick_circuit( + cx_tick_circuit, + decompose_errors=False, + **noise_args, + ) == generate_dem_from_tick_circuit( + szz_tick_circuit, + decompose_errors=False, + **noise_args, + ) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + + saw_physical_prefix = False + saw_virtual_prefix = False + for tick_index in range(tick_circuit.num_ticks()): + tick = tick_circuit.get_tick(tick_index) + labels = _gate_labels_for_tick(tick_circuit, tick_index) + prefix_labels = [label for label in labels if label and label.startswith("szz_")] + if not prefix_labels: + continue + + assert len(prefix_labels) == len(labels) + if prefix_labels[0].startswith("szz_virtual_prefix:"): + saw_virtual_prefix = True + assert all(label.startswith("szz_virtual_prefix:") for label in prefix_labels) + for gate_index, gate in enumerate(tick.gate_batches()): + assert gate.gate_type.name == "Z" + assert tick_circuit.get_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY) == 0.0 + else: + saw_physical_prefix = True + assert all(label.startswith("szz_physical_prefix:") for label in prefix_labels) + assert {gate.gate_type.name for gate in tick.gate_batches()} <= { + "H", + "F", + "Fdg", + "SXdg", + "SY", + } + for gate_index, _gate in enumerate(tick.gate_batches()): + assert tick_circuit.get_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY) is None + + assert saw_physical_prefix + assert saw_virtual_prefix + + +@pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) +def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_model: str) -> None: + patch = SurfacePatch.create(distance=3) + + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + sampling_model=sampling_model, + ) + det_events, obs_flips = sampler.sample(4, seed=20260612) + + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + if sampling_model == "influence_dem": + assert "mechanisms=0" not in repr(sampler.sampler) + + +def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises( + ValueError, + match=r"dedicated idle noise with circuit_source='traced_qis'.*explicit post-flow idle locations", + ): + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", + circuit_source="traced_qis", + ) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) -> None: + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis=basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ traced-QIS p1 test") + noise_args = { + "p1": 0.001, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + } + + native_errors = _raw_dem_errors( + generate_dem_from_tick_circuit( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} traced-QIS SZZ p1 DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + +def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + circuit_source="traced_qis", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_public_traced_qis_dem_matches_stim_with_z_frame_p1_free(basis: str) -> None: + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis=basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ public traced-QIS p1 test") + noise = NoiseModel(p1=0.001) + + native_errors = _raw_dem_errors( + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis="szz", + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + p1=noise.p1, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p1_gate_rates={"Z": 0.0, "SZ": 0.0, "SZdg": 0.0}, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} public traced-QIS SZZ p1 DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + +def test_szz_z_frame_gates_are_p1_free_for_native_noise() -> None: + from types import SimpleNamespace + + from pecos.qec.surface.decode import _szz_z_frame_p1_gate_rates + + assert _szz_z_frame_p1_gate_rates(SimpleNamespace(z_frame_gate_p1_free=True)) == { + "Z": 0.0, + "SZ": 0.0, + "SZdg": 0.0, + } + assert _szz_z_frame_p1_gate_rates(SimpleNamespace(z_frame_gate_p1_free=False)) is None + + +def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + +@pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) +def test_szz_native_sampler_accepts_idle_with_physical_prefix_lowering(sampling_model: str) -> None: + patch = SurfacePatch.create(distance=3) + + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", + sampling_model=sampling_model, + ) + det_events, obs_flips = sampler.sample(4, seed=20260612) + + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + if sampling_model == "influence_dem": + assert "mechanisms=0" not in repr(sampler.sampler) + + +def test_szz_native_dem_accepts_idle_with_physical_prefix_lowering() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_idle_dem_uses_lowered_prefix_topology(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + patch_key = _surface_patch_cache_key(patch) + noise = NoiseModel(p_idle_z_linear_rate=0.01) + + actual = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + basis=basis, + noise=noise, + interaction_basis="szz", + decompose_errors=False, + ) + lowered = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + True, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + plain = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + True, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + + expected = _dem_string_from_cached_surface_topology( + lowered, + noise, + decompose_errors=False, + ) + assert actual == expected + assert actual != _dem_string_from_cached_surface_topology( + plain, + noise, + decompose_errors=False, + ) + + +def test_szz_virtual_prefix_ticks_do_not_contribute_idle_dem() -> None: + patch = SurfacePatch.create(distance=3) + noise_kwargs = { + "p1": 0.0, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + "p_idle_z_linear_rate": 0.01, + "decompose_errors": False, + } + + tagged = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_physical_prefixes=True, + ) + retagged = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_physical_prefixes=True, + ) + assert _retag_virtual_prefix_duration(retagged, 1.0) > 0 + + tagged.fill_idle_gates() + retagged.fill_idle_gates() + + tagged_dem = generate_dem_from_tick_circuit(tagged, **noise_kwargs) + retagged_dem = generate_dem_from_tick_circuit(retagged, **noise_kwargs) + + assert ( + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + basis="Z", + noise=NoiseModel(p_idle_z_linear_rate=0.01), + interaction_basis="szz", + decompose_errors=False, + ) + == tagged_dem + ) + assert retagged_dem != tagged_dem diff --git a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py index 11f2e90ad..45db9a669 100644 --- a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py +++ b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py @@ -104,6 +104,20 @@ def xor_effect_rows(left: dict[str, list[int]], right: dict[str, list[int]]) -> ) +def xor_source_components(row: dict[str, object]) -> tuple[list[int], list[int]]: + """XOR a structured row's source component effects.""" + dets: list[int] = [] + outputs: list[int] = [] + for part_dets, part_outputs in zip( + row["source_component_detectors"], + row["source_component_dem_outputs"], + strict=True, + ): + dets = xor_lists(dets, list(part_dets)) + outputs = xor_lists(outputs, list(part_outputs)) + return dets, outputs + + def parse_dem_error_probabilities(dem_str: str) -> dict[str, float]: """Map DEM target strings to their stated error probabilities.""" out: dict[str, float] = {} @@ -204,6 +218,7 @@ def _find_gate_attrs( phase: str | None = None, label_prefix: str | None = None, stabilizer: str | None = None, + syndrome_round: int | None = None, ) -> dict[str, object]: """Find the first DAG gate attribute record matching the requested filters.""" for node in sorted(dag.nodes()): @@ -217,10 +232,13 @@ def _find_gate_attrs( continue if stabilizer is not None and attrs.get("stabilizer") != stabilizer: continue + if syndrome_round is not None and attrs.get("syndrome_round") != syndrome_round: + continue return attrs msg = ( f"no gate attrs found for gate_type={gate_type!r}, phase={phase!r}, " - f"label_prefix={label_prefix!r}, stabilizer={stabilizer!r}" + f"label_prefix={label_prefix!r}, stabilizer={stabilizer!r}, " + f"syndrome_round={syndrome_round!r}" ) raise AssertionError(msg) @@ -238,18 +256,24 @@ def test_surface_tick_gate_metadata_preserves_phase_round_context_in_dag() -> No assert h_pre["syndrome_round"] == 0 assert "cx_round" not in h_pre - ancilla_reset = _find_gate_attrs(dag, "PZ", phase="syndrome_prep", label_prefix="ax") - assert ancilla_reset["phase"] == "syndrome_prep" - assert ancilla_reset["syndrome_round"] == 1 - assert "cx_round" not in ancilla_reset - assert ancilla_reset["stabilizer"] == "X0" - assert ancilla_reset["stabilizer_kind"] == "X" - assert ancilla_reset["stabilizer_index"] == 0 - assert ancilla_reset["stabilizer_is_boundary"] is True - assert ancilla_reset["stabilizer_region"] - assert ancilla_reset["ancilla_qubit"] >= patch.num_data - - cx = _find_gate_attrs(dag, "CX", phase="cx_round_1") + ancilla_prep = _find_gate_attrs( + dag, + "QAlloc", + phase="syndrome_prep", + label_prefix="ax", + syndrome_round=1, + ) + assert ancilla_prep["phase"] == "syndrome_prep" + assert ancilla_prep["syndrome_round"] == 1 + assert "cx_round" not in ancilla_prep + assert ancilla_prep["stabilizer"] == "X0" + assert ancilla_prep["stabilizer_kind"] == "X" + assert ancilla_prep["stabilizer_index"] == 0 + assert ancilla_prep["stabilizer_is_boundary"] is True + assert ancilla_prep["stabilizer_region"] + assert ancilla_prep["ancilla_qubit"] >= patch.num_data + + cx = _find_gate_attrs(dag, "CX", phase="cx_round_1", syndrome_round=0) assert cx["phase"] == "cx_round_1" assert cx["syndrome_round"] == 0 assert cx["cx_round"] == 1 @@ -263,7 +287,13 @@ def test_surface_tick_gate_metadata_preserves_phase_round_context_in_dag() -> No assert cx["ancilla_qubit"] >= patch.num_data assert cx["data_qubit"] < patch.num_data - ancilla_measure = _find_gate_attrs(dag, "MZ", phase="measure_ancilla", label_prefix="sx") + ancilla_measure = _find_gate_attrs( + dag, + "MeasureFree", + phase="measure_ancilla", + label_prefix="sx", + syndrome_round=0, + ) assert ancilla_measure["phase"] == "measure_ancilla" assert ancilla_measure["syndrome_round"] == 0 assert ancilla_measure["cx_round"] == 4 @@ -284,13 +314,13 @@ def test_surface_tick_gate_metadata_tracks_reused_ancillas_by_label() -> None: dag = tc.to_dag_circuit() first_alloc = _find_gate_attrs(dag, "QAlloc", phase="syndrome_prep", label_prefix="ax0") - reused_reset = _find_gate_attrs(dag, "PZ", phase="syndrome_prep", label_prefix="ax1") + reused_alloc = _find_gate_attrs(dag, "QAlloc", phase="syndrome_prep", label_prefix="ax1") reused_cx = _find_gate_attrs(dag, "CX", phase="cx_round_1", stabilizer="X1") assert first_alloc["stabilizer"] == "X0" - assert reused_reset["stabilizer"] == "X1" - assert reused_reset["ancilla_qubit"] == first_alloc["ancilla_qubit"] - assert reused_reset["ancilla_qubit"] == patch.num_data + assert reused_alloc["stabilizer"] == "X1" + assert reused_alloc["ancilla_qubit"] == first_alloc["ancilla_qubit"] + assert reused_alloc["ancilla_qubit"] == patch.num_data assert reused_cx["stabilizer"] == "X1" assert reused_cx["ancilla_qubit"] == patch.num_data @@ -481,20 +511,23 @@ def test_structured_source_tracking_bindings_are_self_consistent(basis: str) -> @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_source_tracking_y_decomposed_rows_xor_back_to_effect(basis: str) -> None: - """Y-decomposed structured rows should XOR back to their parent effect.""" +def test_structured_source_component_rows_xor_back_to_effect(basis: str) -> None: + """Source component rows should XOR back to their parent effect.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) - summaries = [row for row in dem.contribution_effect_summaries() if row["y_decomposed_count"] > 0] - assert summaries + rows = [] + for summary in dem.contribution_effect_summaries(): + for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): + if "source_component_detectors" not in row: + continue + rows.append((summary, row)) - for summary in summaries[:20]: - contributions = dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]) - y_rows = [row for row in contributions if row["source_type"] == "YDecomposed"] - assert y_rows - for row in y_rows: - assert xor_lists(row["x_detectors"], row["z_detectors"]) == summary["detectors"] - assert xor_lists(row["x_dem_outputs"], row["z_dem_outputs"]) == summary["dem_outputs"] + assert rows + + for summary, row in rows[:100]: + dets, outputs = xor_source_components(row) + assert dets == summary["detectors"] + assert outputs == summary["dem_outputs"] @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -542,20 +575,9 @@ def test_structured_one_sided_direct_component_rows_are_exposed(basis: str) -> N assert rows for summary, row in rows[:100]: - left_non_empty = bool(row["component_1_detectors"] or row["component_1_dem_outputs"]) - right_non_empty = bool(row["component_2_detectors"] or row["component_2_dem_outputs"]) - assert left_non_empty != right_non_empty - assert row["direct_source_family"] == "TwoLocationOneSidedComponent" - direct_dets, direct_logs = xor_effect_rows( - { - "detectors": row["component_1_detectors"], - "dem_outputs": row["component_1_dem_outputs"], - }, - { - "detectors": row["component_2_detectors"], - "dem_outputs": row["component_2_dem_outputs"], - }, - ) + assert "source_component_detectors" in row + assert "source_component_dem_outputs" in row + direct_dets, direct_logs = xor_source_components(row) assert direct_dets == summary["detectors"] assert direct_logs == summary["dem_outputs"] @@ -576,7 +598,8 @@ def test_structured_direct_source_families_are_exposed_for_direct_rows(basis: st assert rows assert all("direct_source_family" in row for row in rows) assert any(row["direct_source_family"] == "SingleLocationY" for row in rows) - assert any(row["direct_source_family"] == "TwoLocationOneSidedComponent" for row in rows) + assert any(row["direct_source_family"] == "TwoLocationComponent" for row in rows) + assert any(row["source_type"] == "DirectOneSidedComponent" for row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -626,7 +649,7 @@ def test_structured_render_summaries_reproduce_decomposed_regrouping(basis: str) assert probability == pytest.approx(decomposed_by_targets[targets], abs=5e-7) assert all("source_type_counts" in row for row in render_summaries) - assert any("YDecomposed" in row["source_type_counts"] for row in render_summaries) + assert any("DirectOneSidedComponent" in row["source_type_counts"] for row in render_summaries) @pytest.mark.parametrize("basis", ["X", "Z"]) diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py index 97da0d66a..ff2d2e80e 100644 --- a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -17,6 +17,7 @@ DemSampler, DetectorErrorModel, ) +from pecos_rslib.quantum import Gate, GateType def _one_measurement_dag(*, num_measurements: str = "1") -> DagCircuit: @@ -49,6 +50,38 @@ def test_valid_metadata_builds_on_all_paths() -> None: assert builder.build().num_detectors == 1 +def test_exact_measurement_crosstalk_payload_emits_python_source_record() -> None: + dag = DagCircuit() + prep = dag.add_gate(Gate(GateType.Prep, qubits=[0])) + payload = dag.add_gate(Gate(GateType.MeasCrosstalkLocalPayload, qubits=[0])) + meas = dag.add_gate(Gate(GateType.Measure, qubits=[0])) + dag.connect(prep, payload, 0) + dag.connect(payload, meas, 0) + dag.set_attr("num_measurements", "1") + dag.set_attr("detectors", '[{"id": 0, "records": [-1]}]') + + im = DagFaultAnalyzer(dag).build_influence_map() + builder = DemBuilder(im) + builder.with_noise( + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_meas_crosstalk_local=0.25, + p_meas_crosstalk_model={"0->1": 0.4}, + measurement_crosstalk_dem_mode="exact_deterministic", + ) + builder.with_num_measurements(1) + builder.with_detectors_json('[{"id": 0, "records": [-1]}]') + builder.with_exact_branch_replay_circuit(dag) + dem = builder.build_with_source_tracking() + + records = dem.contribution_render_records() + assert len(records) == 1 + assert records[0]["direct_source_family"] == "MeasurementCrosstalk" + assert records[0]["gate_type_labels"] == ["MeasCrosstalkLocalPayload"] + + # --- out-of-range record offsets ------------------------------------------- diff --git a/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py b/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py index dd4f7d943..d4dd35a59 100644 --- a/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py +++ b/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py @@ -12,6 +12,7 @@ import numpy as np import pytest +from pecos.qec.surface import get_measurement_order_from_tick_circuit if TYPE_CHECKING: from pecos.quantum import DagCircuit, TickCircuit @@ -21,34 +22,8 @@ def extract_measurement_order(tc: "TickCircuit") -> list[int]: - """Extract measurement order from TickCircuit. - - Returns a list of qubit indices in the order they were measured. - This is needed to map detector record offsets (which use TickCircuit - measurement indices) to influence map indices (which use DAG order). - - Args: - tc: TickCircuit to extract measurement order from. - - Returns: - List of qubit indices in measurement execution order. - """ - measurement_order = [] - - for tick_idx in range(tc.num_ticks()): - tick = tc.get_tick(tick_idx) - if tick is None: - continue - for gate in tick.gate_batches(): - gate_type = str(gate.gate_type) - if "MZ" in gate_type: - for qubit in gate.qubits: - if hasattr(qubit, "index"): - measurement_order.append(qubit.index()) - else: - measurement_order.append(int(qubit)) - - return measurement_order + """Extract measurement order from TickCircuit.""" + return get_measurement_order_from_tick_circuit(tc) def parse_dem_string(dem_str: str) -> dict[tuple, float]: @@ -174,7 +149,11 @@ def test_dem_mechanism_counts_match( **noise_params, decompose_errors=False, ) - stim_dem = generate_dem_from_tick_circuit_via_stim(tc, **noise_params) + stim_dem = generate_dem_from_tick_circuit_via_stim( + tc, + **noise_params, + decompose_errors=False, + ) comparison = compare_dems(pecos_dem, stim_dem) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 5d493b5fc..11af30ae6 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -3,18 +3,35 @@ """Regression tests for the Guppy-to-DEM convenience path.""" +import json +from typing import ClassVar + import pytest from guppylang import guppy -from guppylang.std.builtins import result +from guppylang.std.builtins import barrier, owned, result from guppylang.std.quantum import h, measure, qubit, x from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel -from pecos.qec.surface import SurfacePatch +from pecos.qec.surface import NoiseModel, SurfacePatch +from pecos.qec.surface.circuit_builder import ( + generate_tick_circuit_from_patch, + normalize_traced_qis_tick_circuit, +) from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, + _copy_surface_tick_circuit_metadata, + _extract_measurement_meas_ids, + _measurement_index_remap_for_orders, _reject_partially_lowered_trace, + _remap_surface_record_metadata_json, _replay_lowered_qis_trace_into_tick_circuit, _replay_qis_trace_into_tick_circuit, + _surface_runtime_measurement_remap_from_result_traces, + _validate_result_tag_remap_against_traced_measurements, + capture_guppy_operation_trace, + generate_circuit_level_dem_from_builder, + named_result_traces_from_operation_trace, + trace_guppy_into_tick_circuit_with_result_traces, ) @@ -38,6 +55,103 @@ def _measurement_feedback() -> None: result("b1", b1) +@guppy.declare +def pecos_qis_trace_metadata_qubit_hugr(q: qubit @ owned, key: str, value: str) -> qubit: ... + + +@guppy +def _metadata_before_h_gate() -> None: + q = qubit() + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_kind", "szz_data_prefix") + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_label", "probe:prefix") + q = pecos_qis_trace_metadata_qubit_hugr(q, "host_id", "probe:host") + q = pecos_qis_trace_metadata_qubit_hugr(q, "local_role", "basis_prefix") + h(q) + _ = measure(q) + + +@guppy +def _barrier_between_single_qubit_gates() -> None: + q0 = qubit() + q1 = qubit() + h(q0) + barrier(q0, q1) + h(q1) + _ = measure(q0) + _ = measure(q1) + + +def test_operation_trace_capture_uses_trace_friendly_quantum_backend(monkeypatch: pytest.MonkeyPatch) -> None: + import pecos + + def forbidden_stabilizer(): + msg = "trace capture should not validate operations with stabilizer evolution" + raise AssertionError(msg) + + monkeypatch.setattr(pecos, "stabilizer", forbidden_stabilizer) + + chunks = capture_guppy_operation_trace(_single_measurement, num_qubits=1, seed=0) + result_names = [trace.get("name") for trace in named_result_traces_from_operation_trace(chunks)] + + assert "m" in result_names + + +@pytest.mark.xfail( + reason=( + "Guppy's generic public barrier(...) is optimized away (tket DCE) before PECOS " + "collects QIS operations, so no Barrier survives this path. The supported " + "barrier-preserving path is the SZZ runtime-barrier helper " + "(szz_runtime_barriers=...), covered by " + "test_szz_runtime_barrier_survives_into_qis_operation_trace below. strict=True so " + "this XPASSes (and must be removed) if a future tket/Guppy lowering ever " + "preserves the generic barrier." + ), + strict=True, +) +def test_guppy_barrier_survives_into_qis_operation_trace() -> None: + chunks = capture_guppy_operation_trace( + _barrier_between_single_qubit_gates, + num_qubits=2, + seed=0, + ) + operations = [operation for chunk in chunks for operation in chunk.get("operations", [])] + + assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) + + +def test_szz_runtime_barrier_survives_into_qis_operation_trace() -> None: + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) + chunks = capture_guppy_operation_trace( + program, + num_qubits=get_num_qubits(d=3, interaction_basis="szz"), + seed=0, + ) + operations = [operation for chunk in chunks for operation in chunk.get("operations", [])] + + assert any(operation == "Barrier" or "Barrier" in str(operation) for operation in operations) + + +def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: + chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) + lowered_ops = [op for chunk in chunks for op in chunk.get("lowered_quantum_ops", [])] + + assert lowered_ops[1]["gate_type"] == "R1XY" + assert lowered_ops[1]["metadata"] == { + "host_id": "probe:host", + "local_role": "basis_prefix", + "source_kind": "szz_data_prefix", + "source_label": "probe:prefix", + } + assert lowered_ops[-1]["gate_type"] == "MZ" + assert lowered_ops[-1]["metadata"] == {} + + def _dem_text(*, detectors_json: str = "[]", observables_json: str = "[]") -> str: dem = DetectorErrorModel.from_guppy( _single_measurement, @@ -63,6 +177,26 @@ def _flat_mz_ids(tc) -> list[int]: return ids +def _flat_idle_gates(tc) -> list[tuple[list[int], float]]: + dag = tc.to_dag_circuit() + idles: list[tuple[list[int], float]] = [] + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate is not None and gate.gate_type.name == "Idle": + idles.append((list(gate.qubits), float(gate.params[0]))) + return idles + + +def _flat_gate_qubits(tc, gate_type_name: str) -> list[list[int]]: + dag = tc.to_dag_circuit() + gate_qubits: list[list[int]] = [] + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate is not None and gate.gate_type.name == gate_type_name: + gate_qubits.append(list(gate.qubits)) + return gate_qubits + + def test_from_guppy_meas_ids_are_normalized_to_records() -> None: assert _dem_text(detectors_json='[{"id":0,"meas_ids":[0]}]') == _dem_text( detectors_json='[{"id":0,"records":[-1]}]', @@ -126,29 +260,315 @@ def test_lowered_replay_uses_measure_result_ids_directly() -> None: {"Quantum": {"Measure": [1, 42]}}, ], "lowered_quantum_ops": [ - {"gate_type": "MZ", "qubits": [0], "angles": []}, - {"gate_type": "MZ", "qubits": [1], "angles": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "measurement_result_ids": [42]}, + {"gate_type": "MZ", "qubits": [1], "angles": [], "measurement_result_ids": [99]}, ], }, ] tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) - assert _flat_mz_ids(tc) == [99, 42] + assert _flat_mz_ids(tc) == [42, 99] def test_lowered_replay_fails_on_measurement_count_mismatch() -> None: chunks = [ { "operations": [{"Quantum": {"Measure": [0, 7]}}], - "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0, 1], "angles": []}], + "lowered_quantum_ops": [ + {"gate_type": "MZ", "qubits": [0, 1], "angles": [], "measurement_result_ids": [7]}, + ], + }, + ] + + with pytest.raises(ValueError, match="carries 1 measurement_result_ids for 2"): + _replay_lowered_qis_trace_into_tick_circuit(chunks) + + +def test_lowered_replay_fails_on_missing_measurement_result_ids() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 7]}}], + "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": []}], }, ] - with pytest.raises(ValueError, match="More measured qubits"): + with pytest.raises(ValueError, match="missing measurement_result_ids"): _replay_lowered_qis_trace_into_tick_circuit(chunks) +def test_lowered_replay_preserves_runtime_idles() -> None: + chunks = [ + { + "operations": [{"Quantum": {"H": 0}}], + "lowered_quantum_ops": [ + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [20e-9]}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert _flat_idle_gates(tc) == [([0], 20.0)] + + +def test_lowered_replay_preserves_gate_metadata() -> None: + chunks = [ + { + "operations": [{"Quantum": {"H": 0}}], + "lowered_quantum_ops": [ + { + "gate_type": "H", + "qubits": [0], + "angles": [], + "params": [], + "metadata": { + "source_label": "szz_physical_prefix:H:X0:q0", + "source_kind": "szz_prefix", + }, + }, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert tc.get_gate_meta(0, 0, "source_label") == "szz_physical_prefix:H:X0:q0" + assert tc.get_gate_meta(0, 0, "source_kind") == "szz_prefix" + + +def test_lowered_replay_preserves_measurement_crosstalk_payloads() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + { + "gate_type": "MeasCrosstalkLocalPayload", + "qubits": [1, 2], + "angles": [], + "params": [], + }, + { + "gate_type": "MeasCrosstalkGlobalPayload", + "qubits": [3, 4], + "angles": [], + "params": [], + }, + { + "gate_type": "MZ", + "qubits": [0], + "angles": [], + "params": [], + "measurement_result_ids": [0], + }, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert _flat_gate_qubits(tc, "MeasCrosstalkLocalPayload") == [[1, 2]] + assert _flat_gate_qubits(tc, "MeasCrosstalkGlobalPayload") == [[3, 4]] + + +def test_lowered_replay_can_add_global_crosstalk_payloads_from_measurements() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 7]}}], + "lowered_quantum_ops": [ + { + "gate_type": "MZ", + "qubits": [11, 12], + "angles": [], + "params": [], + "measurement_result_ids": [7, 8], + }, + ], + }, + ] + + without_payloads = _replay_lowered_qis_trace_into_tick_circuit(chunks) + with_payloads = _replay_lowered_qis_trace_into_tick_circuit( + chunks, + measurement_crosstalk_topology="global_from_measurements", + ) + + assert _flat_gate_qubits(without_payloads, "MeasCrosstalkGlobalPayload") == [] + assert _flat_gate_qubits(with_payloads, "MeasCrosstalkGlobalPayload") == [ + [11, 12], + ] + assert _flat_mz_ids(with_payloads) == [7, 8] + + +def test_raw_replay_can_add_global_crosstalk_payloads_from_measurements() -> None: + operations = [ + {"AllocateQubit": {"id": 0}}, + {"AllocateResult": {"id": 9}}, + {"Quantum": {"Measure": [0, 9]}}, + ] + + tc = _replay_qis_trace_into_tick_circuit( + operations, + measurement_crosstalk_topology="global_from_measurements", + ) + + assert _flat_gate_qubits(tc, "MeasCrosstalkGlobalPayload") == [[0]] + assert _flat_mz_ids(tc) == [9] + + +def test_replay_rejects_unknown_measurement_crosstalk_topology() -> None: + with pytest.raises(ValueError, match="measurement_crosstalk_topology"): + _replay_lowered_qis_trace_into_tick_circuit( + [], + measurement_crosstalk_topology="local_from_vibes", + ) + + +def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [20e-9]}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_linear_rate=1.0e-3, + ) + + assert dem.num_contributions > 0 + + +def test_lowered_runtime_idles_accept_axis_memory_noise_dem() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [10e-9]}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_x_linear_rate=1.0e-3, + p_idle_y_quadratic_rate=1.0e-4, + ) + + assert dem.num_contributions > 0 + + +def test_from_circuit_accepts_biased_p2_weights() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [1, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "PZ", "qubits": [1], "angles": [], "params": []}, + {"gate_type": "CX", "qubits": [0, 1], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [1], "angles": [], "params": [], "measurement_result_ids": [0]}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + pauli_labels = ( + "IX", + "IY", + "IZ", + "XI", + "XX", + "XY", + "XZ", + "YI", + "YX", + "YY", + "YZ", + "ZI", + "ZX", + "ZY", + "ZZ", + ) + weights = dict.fromkeys(pauli_labels, 0.0) + weights["IX"] = 1.0 + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.01, + p2_weights=weights, + p_meas=0.0, + p_prep=0.0, + ) + + assert dem.num_contributions > 0 + + +def test_from_circuit_accepts_biased_p1_weights() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.01, + p1_weights={"X": 1.0, "Y": 0.0, "Z": 0.0}, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + ) + + assert dem.num_contributions > 0 + + def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: """A trace where every quantum-carrying chunk is also lowered is accepted (this is the real Selene shape; the byte-identical regressions exercise it @@ -157,7 +577,7 @@ def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: chunks = [ { "operations": [{"Quantum": {"Measure": [0, 7]}}], - "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": []}], + "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": [], "measurement_result_ids": [7]}], }, { # allocation/output bookkeeping only; legitimately has no lowered ops "operations": [{"AllocateResult": {"id": 7}}, {"RecordOutput": {"id": 7}}], @@ -217,6 +637,18 @@ def test_non_lowered_replay_preserves_non_sequential_result_ids() -> None: assert _flat_mz_ids(tc) == [77, 3] +def test_non_lowered_replay_preserves_idle_ops() -> None: + operations = [ + {"AllocateQubit": {"id": 10}}, + {"Quantum": {"Idle": [20e-9, 10]}}, + {"Quantum": {"H": 10}}, + ] + + tc = _replay_qis_trace_into_tick_circuit(operations) + + assert _flat_idle_gates(tc) == [([0], 20.0)] + + def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: """Regression: from_guppy(make_surface_code(...)) must work and match the traced_qis reference DEM. A reverted dynamic-control guard had broken this @@ -230,8 +662,7 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: basis, circuit_source="traced_qis", ) - ref.lower_clifford_rotations() - ref.assign_missing_meas_ids() + normalize_traced_qis_tick_circuit(ref, context="from_guppy surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() got = DetectorErrorModel.from_guppy( make_surface_code(distance=3, num_rounds=3, basis=basis), @@ -244,6 +675,37 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: assert got == ref_dem, f"surface from_guppy not byte-identical ({basis})" +@pytest.mark.parametrize("distance", [3, 5]) +def test_from_guppy_szz_surface_code_is_byte_identical_to_reference(distance: int) -> None: + """SZZ-basis surface Guppy generation must match the traced-QIS reference DEM.""" + p = {"p1": 0.0, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + for basis in ("Z", "X"): + patch = SurfacePatch.create(distance=distance) + ref = _build_surface_tick_circuit_for_native_model( + patch, + 3, + basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + normalize_traced_qis_tick_circuit(ref, context="from_guppy SZZ surface reference") + ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() + got = DetectorErrorModel.from_guppy( + make_surface_code( + distance=distance, + num_rounds=3, + basis=basis, + interaction_basis="szz", + ), + num_qubits=get_num_qubits(distance, interaction_basis="szz"), + detectors_json=ref.get_meta("detectors"), + observables_json=ref.get_meta("observables"), + num_measurements=int(ref.get_meta("num_measurements")), + **p, + ).to_string() + assert got == ref_dem, f"SZZ surface from_guppy not byte-identical (d={distance}, {basis})" + + def test_from_guppy_out_of_range_record_fails_loud() -> None: with pytest.raises(ValueError, match=r"out of range|record offset"): _dem_text(detectors_json='[{"id":0,"records":[-2]}]') # only 1 measurement @@ -306,8 +768,7 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): ancilla_budget=budget, circuit_source="traced_qis", ) - ref.lower_clifford_rotations() - ref.assign_missing_meas_ids() + normalize_traced_qis_tick_circuit(ref, context="from_guppy constrained surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **noise).to_string() got = DetectorErrorModel.from_guppy( @@ -326,7 +787,7 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): [ (3, "Z", 2, 1), # small-and-fast, minimum budget (one stabilizer/batch) (3, "X", 2, 2), # asymmetric basis, X/Z paired per batch - (9, "Z", 3, 17), # canonical high-distance stress + (5, "Z", 3, 5), # medium constrained case without high-distance DEM cost ], ) def test_from_guppy_constrained_surface_dem_byte_identical( @@ -348,18 +809,19 @@ def test_from_guppy_constrained_surface_dem_byte_identical( budget=budget, noise=noise, ) - assert got == ref_dem, ( - f"constrained surface from_guppy not byte-identical for " - f"d={d}, budget={budget}, basis={basis}, rounds={rounds}" - ) + assert ( + got == ref_dem + ), f"constrained surface from_guppy not byte-identical for d={d}, budget={budget}, basis={basis}, rounds={rounds}" def test_constrained_surface_traced_metadata_matches_abstract() -> None: - """The traced TickCircuit's surface metadata is copied verbatim from the - abstract reference. Specifically pins that - ``_copy_surface_tick_circuit_metadata`` propagates ``ancilla_budget`` - (the new key added when the constrained codegen landed) alongside the - existing detectors/observables/counts.""" + """Traced surface metadata preserves structure but binds via MeasIds. + + Runtime traces may reorder measurements, so detector/observable metadata + cannot be copied as positional ``records``. It should preserve the same + detector/observable IDs and descriptors while replacing abstract records + with runtime-stable ``meas_ids``. + """ patch = SurfacePatch.create(distance=3) abstract_tc = _build_surface_tick_circuit_for_native_model( patch, @@ -377,8 +839,6 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: ) for key in ( "basis", - "detectors", - "observables", "num_measurements", "num_detectors", "ancilla_budget", @@ -386,10 +846,107 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: a = abstract_tc.get_meta(key) b = traced_tc.get_meta(key) assert a == b, f"metadata mismatch on key {key!r}: abstract={a!r}, traced={b!r}" + + for key in ("detectors", "observables"): + abstract_entries = json.loads(abstract_tc.get_meta(key) or "[]") + traced_entries = json.loads(traced_tc.get_meta(key) or "[]") + assert len(abstract_entries) == len(traced_entries) + for abstract_entry, traced_entry in zip(abstract_entries, traced_entries, strict=True): + assert "records" in abstract_entry + assert "records" not in traced_entry + assert "meas_ids" in traced_entry + assert {k: v for k, v in abstract_entry.items() if k != "records"} == { + k: v for k, v in traced_entry.items() if k != "meas_ids" + } # ancilla_budget specifically must be the requested budget (stored as a string by set_meta). assert traced_tc.get_meta("ancilla_budget") == "2" +@pytest.mark.parametrize("basis", ["X", "Z"]) +def test_native_abstract_surface_dem_uses_record_metadata_only_for_r0(basis: str) -> None: + """Native abstract DEM construction must not mix typed Pauli annotations + with legacy record metadata. + + Public abstract circuits keep typed annotations by default, but the native + surface DEM helper consumes JSON record metadata. Mixing both sources makes + r=0 prep/readout DEMs carry a detectorless logical source that is absent + from the traced-QIS metadata path. + """ + patch = SurfacePatch.create(distance=3) + public_tc = generate_tick_circuit_from_patch(patch, num_rounds=0, basis=basis) + native_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=0, + basis=basis, + circuit_source="abstract", + ) + + assert public_tc.annotations() + assert native_tc.annotations() == [] + assert json.loads(native_tc.get_meta("detectors") or "[]") + assert json.loads(native_tc.get_meta("observables") or "[]") + + noise = NoiseModel(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0) + for decompose_errors in (False, True): + dem_text = generate_circuit_level_dem_from_builder( + patch, + num_rounds=0, + noise=noise, + basis=basis, + circuit_source="abstract", + decompose_errors=decompose_errors, + ) + detectorless_logical_errors = [ + line for line in dem_text.splitlines() if line.startswith("error") and "L" in line and "D" not in line + ] + assert detectorless_logical_errors == [] + + +@pytest.mark.parametrize( + ("distance", "ancilla_budget"), + [ + (3, None), + (3, 2), + (9, None), + (9, 17), + ], +) +@pytest.mark.parametrize("basis", ["X", "Z"]) +@pytest.mark.parametrize("rounds", [0, 1, 3]) +def test_surface_memory_round_count_contract( + distance: int, + ancilla_budget: int | None, + basis: str, + rounds: int, +) -> None: + """Surface memory circuits count only full X/Z syndrome rounds as ``r``. + + Logical SPAM is outside ``r``: the prep phase measures only the random-sign + stabilizer family (X checks for Z-basis memory, Z checks for X-basis + memory), and readout measures all data qubits. Restricted and unrestricted + ancilla schedules must preserve that experiment contract. + """ + patch = SurfacePatch.create(distance=distance) + geom = patch.geometry + num_x_checks = len(geom.x_stabilizers) + num_z_checks = len(geom.z_stabilizers) + init_checks = num_z_checks if basis == "X" else num_x_checks + final_check_detectors = num_x_checks if basis == "X" else num_z_checks + expected_measurements = init_checks + rounds * (num_x_checks + num_z_checks) + geom.num_data + expected_detectors = rounds * (num_x_checks + num_z_checks) + final_check_detectors + + tc = generate_tick_circuit_from_patch( + patch, + num_rounds=rounds, + basis=basis, + ancilla_budget=ancilla_budget, + ) + + assert int(tc.get_meta("num_measurements")) == expected_measurements + assert len(json.loads(tc.get_meta("detectors") or "[]")) == expected_detectors + assert json.loads(tc.get_meta("observables") or "[]") + + @pytest.mark.parametrize(("d", "budget"), [(3, 1), (3, 2), (5, 3)]) def test_constrained_surface_lowered_qubit_stream_within_budget(d: int, budget: int) -> None: """The lowered-trace physical qubit IDs must stay within the budgeted @@ -582,6 +1139,206 @@ def test_copy_surface_metadata_propagates_descriptors() -> None: assert len(obs_desc) > 0 +def test_surface_metadata_records_bind_to_runtime_meas_ids() -> None: + remap = _measurement_index_remap_for_orders( + [0, 1, 0, 2], + [1, 0, 2, 0], + ) + assert remap == {0: 1, 1: 0, 2: 3, 3: 2} + + metadata = json.dumps( + [ + {"id": 0, "records": [-4, -2]}, + {"id": 1, "records": [-3]}, + ], + ) + remapped = json.loads( + _remap_surface_record_metadata_json( + metadata, + measurement_index_remap=remap, + num_measurements=4, + ), + ) + assert remapped == [ + {"id": 0, "meas_ids": [1, 3]}, + {"id": 1, "meas_ids": [0]}, + ] + + existing_meas_ids = json.dumps([{"id": 2, "meas_ids": [0, 3]}]) + rebound = json.loads( + _remap_surface_record_metadata_json( + existing_meas_ids, + measurement_index_remap=remap, + num_measurements=4, + ), + ) + assert rebound == [{"id": 2, "meas_ids": [1, 2]}] + + +def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: + patch = SurfacePatch.create(distance=3) + abstract_tc = generate_tick_circuit_from_patch( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + ) + program = make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=2) + _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( + program, + get_num_qubits(3, ancilla_budget=2), + seed=0, + ) + + remap = _surface_runtime_measurement_remap_from_result_traces( + abstract_tc, + result_traces, + ) + + assert len(remap) == 29 # 4 prep X stabilizers + 2 rounds * 8 stabilizers + 9 final data measurements + assert sorted(remap) == list(range(29)) + assert sorted(remap.values()) == list(range(29)) + + +def test_runtime_result_tags_bind_metadata_when_lowered_measurements_reorder() -> None: + from pecos_rslib.quantum import TickCircuit + + patch = SurfacePatch.create(distance=3) + abstract_tc = generate_tick_circuit_from_patch(patch, num_rounds=0, basis="Z") + result_traces = [ + {"name": "sx0:init:meas:0", "values": [False], "result_ids": [0]}, + {"name": "sx1:init:meas:1", "values": [False], "result_ids": [1]}, + {"name": "sx2:init:meas:2", "values": [False], "result_ids": [2]}, + {"name": "sx3:init:meas:3", "values": [False], "result_ids": [3]}, + { + "name": "final", + "values": [False] * 9, + # Semantic data-qubit order, independent of runtime MZ order. + "result_ids": list(range(4, 13)), + }, + ] + remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) + + traced_tc = TickCircuit() + traced_tc.tick().mz_with_ids([9, 10, 11, 12], [0, 1, 2, 3]) + traced_tc.tick().mz_with_ids( + [5, 0, 4, 1, 8, 3, 7, 6, 2], + [9, 4, 8, 5, 12, 7, 11, 10, 6], + ) + + assert _extract_measurement_meas_ids(traced_tc) != list(range(13)) + _validate_result_tag_remap_against_traced_measurements( + traced_tc, + remap, + expected_measurements=13, + ) + + _copy_surface_tick_circuit_metadata( + abstract_tc, + traced_tc, + measurement_index_remap=remap, + ) + detectors = json.loads(traced_tc.get_meta("detectors")) + observables = json.loads(traced_tc.get_meta("observables")) + assert all("records" not in entry for entry in detectors + observables) + assert all("meas_ids" in entry for entry in detectors + observables) + + logical_z_qubits = list(patch.geometry.logical_z.data_qubits) + assert observables == [{"id": 0, "meas_ids": [4 + q for q in logical_z_qubits]}] + + +def test_result_tag_remap_validation_accepts_exact_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [10, 3]) + + remap = {0: 3, 1: 10} + + assert _extract_measurement_meas_ids(tc) == [10, 3] + _validate_result_tag_remap_against_traced_measurements( + tc, + remap, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_duplicate_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [7, 7]) + + with pytest.raises(ValueError, match="duplicate measured MeasId"): + _validate_result_tag_remap_against_traced_measurements( + tc, + {0: 7, 1: 8}, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_unbound_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [0, 2]) + + with pytest.raises(ValueError, match="do not exactly match"): + _validate_result_tag_remap_against_traced_measurements( + tc, + {0: 0, 1: 1}, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_unstamped_measurements() -> None: + class FakeGate: + gate_type = "MZ" + qubits: ClassVar[list[int]] = [0] + meas_ids: ClassVar[list[int]] = [] + + class FakeTick: + def gate_batches(self): + return [FakeGate()] + + class FakeCircuit: + def num_ticks(self) -> int: + return 1 + + def get_tick(self, tick_idx: int): + assert tick_idx == 0 + return FakeTick() + + with pytest.raises(ValueError, match="carries 0 MeasId"): + _validate_result_tag_remap_against_traced_measurements( + FakeCircuit(), + {0: 0}, + expected_measurements=1, + ) + + +def test_traced_surface_metadata_uses_runtime_result_tags() -> None: + patch = SurfacePatch.create(distance=3) + traced_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + ) + + assert traced_tc.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert traced_tc.get_meta("circuit_source") == "traced_qis" + assert int(traced_tc.get_meta("num_measurements")) == 29 + assert len(json.loads(traced_tc.get_meta("detectors"))) > 0 + assert len(json.loads(traced_tc.get_meta("observables"))) == 1 + + +def test_surface_metadata_record_remap_rejects_measurement_drift() -> None: + with pytest.raises(ValueError, match="measured-qubit multiset"): + _measurement_index_remap_for_orders([0, 1, 0], [0, 1, 2]) + + def test_surface_module_cache_collapses_unconstrained_budget_forms() -> None: """``get_surface_code_module`` keys its cache on the *effective* budget (``normalize_ancilla_budget(d*d-1, budget)``), so ``ancilla_budget=None`` diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py index 160bff950..909f43b89 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py @@ -29,7 +29,7 @@ import pytest from guppylang import guppy from guppylang.std.builtins import result -from guppylang.std.quantum import measure, qubit, x +from guppylang.std.quantum import cx, h, measure, qubit from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -40,9 +40,9 @@ # tag_c -> [2] (ordinals of the measurements the tags actually record, not # the order of the result() calls). # -# Each qubit gets a *different* number of single-qubit gates before measure -# (qa: 0, qb: 1, qc: 2). With p1 > 0 those gates contribute distinct error -# mechanisms touching only that qubit's measurement, so the DEMs for +# The nontrivial pre-history entangles qb and qc after an H on qb. With +# p1/p2 > 0 this produces distinct mechanisms for detectors anchored to qa, +# qb, and qc, so the DEMs for # detectors anchored to records [-3], [-2], [-1] differ in their (number of) # mechanisms / probabilities. A test asserting result_tags equals positional # records is then load-bearing: a wrong ordinal mapping would produce a @@ -54,9 +54,8 @@ def _scrambled_three_measurements() -> None: qa = qubit() qb = qubit() qc = qubit() - x(qb) - x(qc) - x(qc) + h(qb) + cx(qb, qc) a = measure(qa) b = measure(qb) c = measure(qc) @@ -65,7 +64,7 @@ def _scrambled_three_measurements() -> None: result("tag_b", b) -_NOISE = {"p1": 0.01, "p2": 0.0, "p_meas": 0.1, "p_prep": 0.005} +_NOISE = {"p1": 0.01, "p2": 0.02, "p_meas": 0.1, "p_prep": 0.005} def _from_guppy(detectors_json: str, *, observables_json: str = "[]") -> str: diff --git a/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py b/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py index 242cbdcbb..00323d494 100644 --- a/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py +++ b/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py @@ -5,7 +5,7 @@ import pytest from pecos_rslib.quantum import TickCircuit -from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer +from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer def prep_measure_circuit() -> TickCircuit: @@ -43,18 +43,25 @@ def test_tick_circuit_with_noise_rejects_invalid_probabilities() -> None: prep_measure_circuit().with_noise(p2=1.1) -def test_sim_neo_default_routes_inline_channels_through_density_matrix() -> None: +def test_sim_neo_auto_routes_inline_channels_through_density_matrix() -> None: noisy = prep_measure_circuit().with_noise(p_prep=1.0) - result = sim_neo(noisy).shots(5).seed(123).run() + result = sim_neo(noisy).auto().sampling(monte_carlo(5)).seed(123).run() assert measurement_rows(result) == [[1], [1], [1], [1], [1]] +def test_sim_neo_inline_channels_require_explicit_backend_or_auto() -> None: + noisy = prep_measure_circuit().with_noise(p_prep=1.0) + + with pytest.raises(ValueError, match="No quantum backend set"): + sim_neo(noisy).sampling(monte_carlo(5)).run() + + def test_sim_neo_stabilizer_samples_inline_pauli_channels() -> None: noisy = prep_measure_circuit().with_noise(p_prep=1.0) - result = sim_neo(noisy).quantum(stabilizer()).shots(5).seed(123).run() + result = sim_neo(noisy).quantum(stabilizer()).sampling(monte_carlo(5)).seed(123).run() assert measurement_rows(result) == [[1], [1], [1], [1], [1]] diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py index a0564c682..1d7438dea 100644 --- a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py @@ -11,7 +11,7 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model -from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer +from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer @pytest.fixture @@ -32,20 +32,24 @@ def coherent(): class TestD3SurfaceCode57vs48: def test_raw_output_is_57_measurements(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(10)).seed(42).run() + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_nondet_measurement_mean_half(self, d3_tc, depol): shots = 5000 - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() mean_0 = sum(s[0] for s in r) / shots assert abs(mean_0 - 0.5) < 0.05, f"meas[0]={mean_0:.3f}" def test_det_measurement_mean_low(self, d3_tc, depol): shots = 5000 - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - mean_4 = sum(s[4] for s in r) / shots - assert mean_4 < 0.1, f"meas[4]={mean_4:.3f}" + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + # Index 8 is the first round-1 Z-stabilizer measurement; on the |0_L> + # memory experiment it is deterministic (+1). Indices 0-7 are the four + # init-round X-stabilizer measurements plus round-1 X-stabilizers, which + # are random on |0_L>. + mean_det = sum(s[8] for s in r) / shots + assert mean_det < 0.1, f"meas[8]={mean_det:.3f}" def test_z_type_detection_rates_match_stabilizer(self, d3_tc, depol): """Z-type detector rates match stabilizer (known-good subset).""" @@ -67,8 +71,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -104,8 +108,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -134,8 +138,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(noise).shots(shots).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(noise).shots(shots).seed(43).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(noise).sampling(monte_carlo(shots)).seed(43).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -149,29 +153,29 @@ def rates(results): class TestMethodDispatch: def test_auto_no_idle_rz(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(depol).sampling(monte_carlo(10)).seed(42).run() + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_auto_with_idle_rz(self, d3_tc, coherent): - r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(coherent).shots(10).seed(42).run() - assert len(r[0]) == 57 + r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_stochastic_rejects_idle_rz(self, d3_tc, coherent): with pytest.raises(Exception, match="idle_rz"): - sim_neo(d3_tc).quantum(meas_sampling("stochastic")).noise(coherent).shots(10).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling("stochastic")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() def test_coherent_no_idle_rz(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(depol).sampling(monte_carlo(10)).seed(42).run() + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_coherent_with_idle_rz(self, d3_tc, coherent): - r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(coherent).shots(10).seed(42).run() - assert len(r[0]) == 57 + r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_invalid_method(self, d3_tc, depol): with pytest.raises(Exception, match="Unknown"): - sim_neo(d3_tc).quantum(meas_sampling("bogus")).noise(depol).shots(10).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling("bogus")).noise(depol).sampling(monte_carlo(10)).seed(42).run() def test_no_noise_errors(self, d3_tc): with pytest.raises(Exception, match="noise"): - sim_neo(d3_tc).quantum(meas_sampling()).shots(10).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling()).sampling(monte_carlo(10)).seed(42).run() diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py b/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py index 1532453b9..e8bfc1e02 100644 --- a/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py @@ -11,7 +11,7 @@ import pytest from pecos.quantum import TickCircuit -from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer, statevec +from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer, statevec def build_two_round_x_check(): @@ -75,8 +75,8 @@ def test_two_round_meas_fault_both_fire(self): depol = depolarizing().p1(0).p2(0).p_meas(0.01).p_prep(0) shots = 50000 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() # Extract detector rate def det_rate(results): @@ -101,8 +101,8 @@ def test_prep_fault_reaches_next_measurement(self): depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0.01) shots = 50000 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() def det_rate(results): return sum(s[0] ^ s[1] for s in results) / len(results) @@ -122,8 +122,8 @@ def test_prep_fault_does_not_cross_reset(self): depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0.01) shots = 50000 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() # Extract detector 0 (m0 XOR m1) rate def det_rate(results, d): @@ -149,8 +149,8 @@ def test_three_round_z_check_all_noise(self): depol = depolarizing().p1(0.001).p2(0.005).p_meas(0.005).p_prep(0.005) shots = 50000 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() def det_rate(results, d): num_meas = 3 @@ -172,14 +172,14 @@ class TestZeroNoise: def test_two_round_x_check_zero_noise(self): tc = build_two_round_x_check() depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) - r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() + r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() det_fires = sum(s[0] ^ s[1] for s in r) assert det_fires == 0, f"Zero-noise detector fired {det_fires}/1000 times" def test_three_round_z_check_zero_noise(self): tc = build_three_round_z_check() depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) - r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() + r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() for d in [0, 1]: num_meas = 3 recs = [{"records": [-2, -3]}, {"records": [-1, -2]}][d]["records"] @@ -206,8 +206,8 @@ def test_new_clifford_gates_match_stabilizer_zero_noise(self): depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) shots = 32 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(11).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(99).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(11).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(99).run() assert len(meas_r) == len(stab_r) == shots for shot in range(shots): @@ -234,7 +234,7 @@ def test_cy_sign_has_circuit_level_measurement_effect(self): depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) for backend in (stabilizer(), statevec()): - result = sim_neo(tc).quantum(backend).noise(depol).shots(32).seed(123).run() + result = sim_neo(tc).quantum(backend).noise(depol).sampling(monte_carlo(32)).seed(123).run() for shot in range(result.num_shots): row = list(result[shot]) assert row[0] ^ row[1] == 1 @@ -253,7 +253,7 @@ def test_cy_circuit_shape_and_values(self): tc.set_meta("observables", "[]") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - result = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(100)).seed(42).run() assert len(result) == 100 assert len(result[0]) == 2 @@ -276,8 +276,8 @@ def test_cy_matches_stabilizer_protocol(self): depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(100).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(100)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(100)).seed(42).run() assert len(meas_r) == len(stab_r) == 100 assert meas_r.num_measurements == stab_r.num_measurements == 2 diff --git a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py index 84e03a7a5..50d3a1619 100644 --- a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py +++ b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py @@ -19,7 +19,7 @@ def test_sim_neo_stack_runs_from_exp() -> None: pecos_rslib_exp.sim_neo(tc) .quantum(pecos_rslib_exp.stabilizer()) .noise(pecos_rslib_exp.depolarizing()) - .shots(2) + .sampling(pecos_rslib_exp.monte_carlo(2)) .seed(123) .run() ) @@ -54,6 +54,24 @@ def test_surface_code_memory_runs_native_zero_noise_quick_start() -> None: assert result.num_rounds == 1 assert result.logical_error_rate == 0.0 assert result.raw_error_rate == 0.0 + assert result.interaction_basis == "cx" + + +def test_surface_code_memory_accepts_szz_interaction_basis() -> None: + from pecos.qec.surface import surface_code_memory + + result = surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=4, + rounds=1, + seed=123, + interaction_basis="szz", + ) + + assert result.interaction_basis == "szz" + assert result.logical_error_rate == 0.0 + assert result.raw_error_rate == 0.0 def test_surface_code_memory_rejects_ambiguous_noise_inputs() -> None: diff --git a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py index 7bb169a1e..9c6fd044f 100644 --- a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py +++ b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py @@ -11,38 +11,43 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model -from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer +from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer @pytest.fixture def d3_results(): - """Run both backends on the same circuit and return their results.""" + """Run both backends on the same circuit and return their results. + + The third element is the circuit's declared measurement count, used so the + assertions track the actual circuit contract rather than a hard-coded value. + """ patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") + num_meas = int(tc.get_meta("num_measurements")) depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(100).seed(42).run() - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() - return stab_r, meas_r + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(100)).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(100)).seed(42).run() + return stab_r, meas_r, num_meas class TestCommonProtocol: """Both backends return objects with the same interface.""" def test_len(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results assert len(stab_r) == 100 assert len(meas_r) == 100 def test_indexing(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results # r[shot] returns a sequence of u8 values s0 = stab_r[0] d0 = meas_r[0] - assert len(s0) == len(d0) == 57 # d=3 surface code has 57 measurements + assert len(s0) == len(d0) == num_meas def test_item_values(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # Individual values are 0 or 1 for val in stab_r[0]: assert val in (0, 1) @@ -50,80 +55,80 @@ def test_item_values(self, d3_results): assert val in (0, 1) def test_list_conversion(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results s_row = list(stab_r[0]) d_row = list(meas_r[0]) assert all(isinstance(v, int) for v in s_row) assert all(isinstance(v, int) for v in d_row) - assert len(s_row) == len(d_row) == 57 + assert len(s_row) == len(d_row) == num_meas def test_iteration(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results stab_count = 0 for row in stab_r: stab_count += 1 - assert len(row) == 57 + assert len(row) == num_meas assert stab_count == 100 dem_count = 0 for row in meas_r: dem_count += 1 - assert len(row) == 57 + assert len(row) == num_meas assert dem_count == 100 def test_out_of_range_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[100] with pytest.raises(IndexError): meas_r[100] def test_num_shots_property(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results assert stab_r.num_shots == 100 assert meas_r.num_shots == 100 def test_num_measurements_property(self, d3_results): - stab_r, meas_r = d3_results - assert stab_r.num_measurements == 57 - assert meas_r.num_measurements == 57 + stab_r, meas_r, num_meas = d3_results + assert stab_r.num_measurements == num_meas + assert meas_r.num_measurements == num_meas def test_get_method(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # get(shot, meas) returns 0 or 1 assert stab_r.get(0, 0) in (0, 1) assert meas_r.get(0, 0) in (0, 1) def test_get_out_of_range(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results with pytest.raises(IndexError): stab_r.get(100, 0) with pytest.raises(IndexError): - meas_r.get(0, 57) + meas_r.get(0, num_meas) def test_get_shot_method(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results s = stab_r.get_shot(0) d = meas_r.get_shot(0) - assert len(s) == len(d) == 57 + assert len(s) == len(d) == num_meas def test_to_list(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results sl = stab_r.to_list() dl = meas_r.to_list() assert len(sl) == len(dl) == 100 - assert len(sl[0]) == len(dl[0]) == 57 + assert len(sl[0]) == len(dl[0]) == num_meas def test_negative_index_raises_index_error(self, d3_results): """Negative indexing raises IndexError, never OverflowError.""" - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[-1] with pytest.raises(IndexError): meas_r[-1] def test_negative_get_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # Negative shot with pytest.raises(IndexError): stab_r.get(-1, 0) @@ -136,7 +141,7 @@ def test_negative_get_raises_index_error(self, d3_results): meas_r.get(0, -1) def test_get_shot_negative_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r.get_shot(-1) with pytest.raises(IndexError): @@ -144,7 +149,7 @@ def test_get_shot_negative_raises_index_error(self, d3_results): def test_out_of_range_uses_len(self, d3_results): """result[len(result)] raises IndexError.""" - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[len(stab_r)] with pytest.raises(IndexError): @@ -170,10 +175,10 @@ def test_generic_consumer_stabilizer(self): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - result = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(1000).seed(42).run() + result = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() means = self.compute_measurement_means(result) - assert len(means) == 57 + assert len(means) == int(tc.get_meta("num_measurements")) # Non-det measurements should be ~0.5, det should be ~0 nondet = sum(1 for m in means if abs(m - 0.5) < 0.15) assert nondet > 0 @@ -182,9 +187,9 @@ def test_generic_consumer_meas_sampling(self): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - result = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() means = self.compute_measurement_means(result) - assert len(means) == 57 + assert len(means) == int(tc.get_meta("num_measurements")) nondet = sum(1 for m in means if abs(m - 0.5) < 0.15) assert nondet > 0 diff --git a/python/quantum-pecos/tests/qec/test_reliable_observables.py b/python/quantum-pecos/tests/qec/test_reliable_observables.py new file mode 100644 index 000000000..6e7715625 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_reliable_observables.py @@ -0,0 +1,103 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Validate the reliable-observable prototype against the paper's examples. + +Reproduces the fragile-observable example from Serra-Peralta et al. +(arXiv:2505.13599): two fragile observables whose product is reliable. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +stim = pytest.importorskip("stim") + +from pecos.qec.reliable_observables import ( # noqa: E402 + _gf2_right_null_space, + is_reliable, + reliable_observables, +) + + +def test_gf2_right_null_space(): + a = np.array([[1, 1, 0], [0, 1, 1]], dtype=np.uint8) + basis = _gf2_right_null_space(a) + # rank 2, 3 cols -> 1-d null space, spanned by [1,1,1]. + assert len(basis) == 1 + assert basis[0].tolist() == [1, 1, 1] + for v in basis: + assert np.all((a @ v) % 2 == 0) + + +def test_gf2_full_rank_has_trivial_null_space(): + a = np.eye(3, dtype=np.uint8) + assert _gf2_right_null_space(a) == [] + + +def _bell_circuit() -> stim.Circuit: + # Paper's fragile example (eq. Bell_state_fragile_observables): + # q0=|0>, q1=|+>, CX(1,0), measure both in Z. O0={M(q0)}, O1={M(q1)} are + # each fragile; their product is reliable (and deterministic). + c = stim.Circuit() + c.append("RZ", [0]) + c.append("RX", [1]) + c.append("TICK") + c.append("CX", [1, 0]) + c.append("TICK") + c.append("M", [0]) + c.append("M", [1]) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-2)], 0) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-1)], 1) + return c + + +def test_bell_fragile_observables_product_is_reliable(): + """The paper's headline example: O0, O1 fragile; O0*O1 reliable.""" + c = _bell_circuit() + assert reliable_observables(c) == [{0, 1}] + assert is_reliable(c, {0, 1}) + assert not is_reliable(c, 0) + assert not is_reliable(c, 1) + + +def test_memory_single_observable_is_reliable(): + c = stim.Circuit() + c.append("RZ", [0]) + c.append("TICK") + c.append("M", [0]) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-1)], 0) + assert reliable_observables(c) == [{0}] + assert is_reliable(c, 0) + + +def test_runs_on_pecos_surface_memory_circuit(): + """End-to-end on a real PECOS-generated circuit: a surface-code Z memory has + a single reliable logical observable.""" + from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch + + patch = SurfacePatch.create(distance=3) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + circuit = stim.Circuit(b.to_stim(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0)) + assert circuit.num_observables >= 1 + rel = reliable_observables(circuit) + # Every raw observable should be reliable on its own for a plain memory. + for o in range(circuit.num_observables): + assert is_reliable(circuit, o), f"observable {o} unexpectedly fragile" + assert rel # non-empty reliable basis + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py b/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py new file mode 100644 index 000000000..bb6e1facf --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py @@ -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. + +"""sim_neo Python bindings mirror the Rust builder's explicit-by-default rules. + +No silent backend, shot count, or seed. Missing required config fails with +the same messages as the Rust builder; .auto() opts into automatic selection. +""" + +from __future__ import annotations + +import pytest + +pecos_rslib_exp = pytest.importorskip("pecos_rslib_exp") + +from pecos.quantum import TickCircuit # noqa: E402 +from pecos_rslib_exp import monte_carlo, sim_neo, stabilizer # noqa: E402 + + +def one_qubit_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().x([0]) + tc.tick().mz([0]) + return tc + + +def test_missing_quantum_backend_is_error() -> None: + with pytest.raises(ValueError, match="No quantum backend set"): + sim_neo(one_qubit_circuit()).sampling(monte_carlo(2)).run() + + +def test_missing_sampling_is_error() -> None: + with pytest.raises(ValueError, match="No sampling strategy set"): + sim_neo(one_qubit_circuit()).quantum(stabilizer()).run() + + +def test_auto_selects_stabilizer_backend() -> None: + result = sim_neo(one_qubit_circuit()).auto().sampling(monte_carlo(3)).seed(7).run() + assert result.num_shots == 3 + assert [list(shot) for shot in result] == [[1], [1], [1]] + + +def test_explicit_quantum_overrides_auto() -> None: + result = sim_neo(one_qubit_circuit()).auto().quantum(stabilizer()).sampling(monte_carlo(2)).seed(7).run() + assert result.num_shots == 2 + + +def test_deprecated_shots_forwarder_warns_and_works() -> None: + with pytest.deprecated_call(): + builder = sim_neo(one_qubit_circuit()).quantum(stabilizer()).shots(2) + result = builder.seed(7).run() + assert result.num_shots == 2 + + +def test_deprecated_shots_conflicts_with_sampling() -> None: + with pytest.deprecated_call(): + builder = sim_neo(one_qubit_circuit()).quantum(stabilizer()).shots(2) + with pytest.raises(ValueError, match=r"deprecated \.shots\(\) cannot be combined"): + builder.sampling(monte_carlo(5)).run() diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py b/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py new file mode 100644 index 000000000..c227baff7 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py @@ -0,0 +1,85 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Path enumeration through the sim_neo Python bindings. + +Mirrors the Rust tool API: each distinct realized measurement-branch path is +one result row, with its exact probability in `result.weights`. +""" + +from __future__ import annotations + +import pytest + +pecos_rslib_exp = pytest.importorskip("pecos_rslib_exp") + +from pecos.quantum import TickCircuit # noqa: E402 +from pecos_rslib_exp import ( # noqa: E402 + depolarizing, + monte_carlo, + path_enumeration, + sim_neo, + stabilizer, +) + + +def bell_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().cx([(0, 1)]) + tc.tick().mz([0, 1]) + return tc + + +def deterministic_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().x([0]) + tc.tick().mz([0]) + return tc + + +def test_bell_pair_enumerates_two_correlated_paths() -> None: + result = sim_neo(bell_circuit()).quantum(stabilizer()).sampling(path_enumeration(1)).run() + + assert result.num_shots == 2 + weights = result.weights + assert weights is not None + assert abs(sum(weights) - 1.0) < 1e-12 + for row, weight in zip(result, weights, strict=True): + assert abs(weight - 0.5) < 1e-12 + assert row[0] == row[1], "Bell pair outcomes must be correlated" + + +def test_deterministic_circuit_dedupes_to_one_path() -> None: + result = sim_neo(deterministic_circuit()).auto().sampling(path_enumeration(2)).run() + + assert result.num_shots == 1 + assert result.weights == [1.0] + assert list(result[0]) == [1] + + +def test_monte_carlo_results_have_no_weights() -> None: + result = sim_neo(deterministic_circuit()).quantum(stabilizer()).sampling(monte_carlo(3)).seed(1).run() + assert result.weights is None + + +def test_path_enumeration_rejects_noise() -> None: + with pytest.raises(ValueError, match=r"remove \.noise\(\)"): + sim_neo(bell_circuit()).quantum(stabilizer()).noise(depolarizing().p1(0.01)).sampling( + path_enumeration(1), + ).run() + + +def test_path_enumeration_rejects_huge_enumeration() -> None: + with pytest.raises(ValueError, match="more than 16M paths"): + sim_neo(bell_circuit()).quantum(stabilizer()).sampling(path_enumeration(25)).run() diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py b/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py new file mode 100644 index 000000000..ced5f4931 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py @@ -0,0 +1,155 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Subset simulation through the sim_neo Python bindings. + +Mirrors the Rust tool API: score/failure are Python callables receiving the +sample's measurement bits as list[int]; the estimate arrives in +result.subset (rows are empty for subset runs). +""" + +from __future__ import annotations + +import pytest + +pecos_rslib_exp = pytest.importorskip("pecos_rslib_exp") + +from pecos.quantum import TickCircuit # noqa: E402 +from pecos_rslib_exp import ( # noqa: E402 + sim_neo, + stabilizer, + statevec, + subset_simulation, +) + + +def three_h_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().h([0, 1, 2]) + tc.tick().mz([0, 1, 2]) + return tc + + +def x_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().x([0]) + tc.tick().mz([0]) + return tc + + +def test_subset_estimates_known_probability() -> None: + # P(all three H measurements give 1) = 1/8. + result = ( + sim_neo(three_h_circuit()) + .quantum(stabilizer()) + .sampling( + subset_simulation(2000) + .score(lambda bits: float(sum(bits))) + .failure(lambda bits: all(b == 1 for b in bits)), + ) + .seed(42) + .run() + ) + + assert result.num_shots == 0, "Subset runs produce an estimate, not rows" + subset = result.subset + assert subset is not None + assert 0.08 <= subset.probability <= 0.20 + assert subset.total_samples >= 2000 + lower, upper = subset.confidence_interval_95() + assert lower <= subset.probability <= upper + assert len(subset.levels()) >= 1 + + +def test_subset_certain_event() -> None: + result = ( + sim_neo(x_circuit()) + .auto() + .sampling( + subset_simulation(200).score(lambda bits: float(sum(bits))).failure(lambda bits: bits[0] == 1), + ) + .seed(7) + .run() + ) + assert abs(result.subset.probability - 1.0) < 1e-9 + + +def test_subset_deterministic_with_seed() -> None: + def run() -> float: + return ( + sim_neo(three_h_circuit()) + .auto() + .sampling( + subset_simulation(500) + .score(lambda bits: float(sum(bits))) + .failure(lambda bits: all(b == 1 for b in bits)), + ) + .seed(99) + .run() + .subset.probability + ) + + assert run() == run() + + +def test_subset_requires_score_and_failure() -> None: + with pytest.raises(ValueError, match=r"requires both \.score\(\.\.\) and \.failure\(\.\.\)"): + sim_neo(three_h_circuit()).auto().sampling(subset_simulation(100)).run() + + +def test_subset_rejects_statevec_backend() -> None: + with pytest.raises(ValueError, match="only the stabilizer"): + sim_neo(three_h_circuit()).quantum(statevec()).sampling( + subset_simulation(100).score(lambda bits: float(sum(bits))).failure(lambda bits: all(b == 1 for b in bits)), + ).run() + + +def test_subset_callable_exception_propagates() -> None: + def bad_score(_bits: list[int]) -> float: + msg = "score exploded" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError, match="score exploded"): + sim_neo(three_h_circuit()).auto().sampling( + subset_simulation(50).score(bad_score).failure(lambda _bits: False), + ).run() + + +def _ones_score(bits: list[int]) -> float: + return float(sum(bits)) + + +def _all_ones(bits: list[int]) -> bool: + return all(b == 1 for b in bits) + + +def test_subset_multilevel_without_opt_in_raises() -> None: + """max_levels > 1 engages the biased multi-level estimator and must be + refused unless explicitly acknowledged (mirrors the Rust guard).""" + with pytest.raises(ValueError, match="biased upward"): + sim_neo(three_h_circuit()).auto().sampling( + subset_simulation(100).score(_ones_score).failure(_all_ones).max_levels(5), + ).run() + + +def test_subset_multilevel_runs_with_opt_in() -> None: + result = ( + sim_neo(three_h_circuit()) + .auto() + .sampling( + subset_simulation(500).score(_ones_score).failure(_all_ones).max_levels(5).allow_biased_multilevel(), + ) + .seed(42) + .run() + ) + assert result.subset is not None diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 47b0ca312..50116aba0 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -3,9 +3,12 @@ """Smoke tests for the traced-QIS surface-code route after Clifford lowering.""" +import math import random +import pytest from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model from pecos.quantum import TickCircuit from pecos_rslib_exp import ( @@ -14,6 +17,7 @@ depolarizing, fault_catalog, meas_sampling, + monte_carlo, sim_neo, stabilizer, statevec, @@ -79,7 +83,7 @@ def build_lowered_traced_qis_surface_code(rounds=3): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, rounds, "Z", circuit_source="traced_qis") - tc.lower_clifford_rotations() + normalize_traced_qis_tick_circuit(tc, context="test traced-QIS surface code") return tc @@ -179,7 +183,7 @@ def test_meas_sampling_runs_on_lowered_traced_qis_surface_code(): tc = build_lowered_traced_qis_surface_code() shots = 8 - result = sim_neo(tc).quantum(meas_sampling()).noise(traced_qis_noise()).shots(shots).seed(123).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(traced_qis_noise()).sampling(monte_carlo(shots)).seed(123).run() assert result.num_shots == shots assert result.num_measurements == int(tc.get_meta("num_measurements")) @@ -202,7 +206,7 @@ def test_lowered_traced_qis_pipeline_sampling_and_catalog_smoke(): tc = build_lowered_traced_qis_surface_code(rounds=2) noise = traced_qis_noise() - result = sim_neo(tc).quantum(meas_sampling()).noise(noise).shots(3).seed(321).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(3)).seed(321).run() catalog = fault_catalog(tc, noise) first_fault = next(catalog.fault_configurations(1)) @@ -213,11 +217,111 @@ def test_lowered_traced_qis_pipeline_sampling_and_catalog_smoke(): assert len(first_fault.faults) == 1 +def test_normalize_traced_qis_tick_circuit_lowers_clifford_rzz(): + tc = TickCircuit() + tc.tick().rzz(math.pi / 2, [(0, 1)]) + + normalize_traced_qis_tick_circuit(tc, context="test Clifford RZZ normalization") + + gate_names = [ + gate.gate_type.name for tick_index in range(tc.num_ticks()) for gate in tc.get_tick(tick_index).gate_batches() + ] + assert "RZZ" not in gate_names + assert "SZZ" in gate_names + + +def test_normalize_traced_qis_tick_circuit_rejects_raw_rzz_after_lowering(): + tc = TickCircuit() + tc.tick().rzz(math.pi / 4, [(0, 1)]) + + with pytest.raises(ValueError, match="still contains raw RZZ"): + normalize_traced_qis_tick_circuit(tc, context="test non-Clifford RZZ normalization") + + +def _gate_names(tc): + return [ + gate.gate_type.name for tick_index in range(tc.num_ticks()) for gate in tc.get_tick(tick_index).gate_batches() + ] + + +def test_tick_circuit_pass_bindings_cancel_and_remove_simple_gates(): + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().h([0]) + tc.tick().i([1]) + + tc.cancel_inverses() + tc.remove_identity() + + assert _gate_names(tc) == [] + + +def test_tick_circuit_pass_bindings_merge_and_lower_rotations(): + tc = TickCircuit() + tc.tick().rz(math.pi / 4, [0]) + tc.tick().rz(math.pi / 4, [0]) + + tc.merge_adjacent_rotations() + tc.lower_clifford_rotations() + + assert _gate_names(tc) == ["SZ"] + + +def test_tick_circuit_pass_bindings_absorb_basis_and_peephole(): + absorbed = TickCircuit() + absorbed.tick().pz([0]) + absorbed.tick().sz([0]) + absorbed.tick().mz([0]) + absorbed.absorb_basis_gates() + assert _gate_names(absorbed) == ["PZ", "MZ"] + + optimized = TickCircuit() + optimized.tick().h([1]) + optimized.tick().cx([(0, 1)]) + optimized.tick().h([1]) + optimized.peephole_optimize() + assert _gate_names(optimized) == ["CZ"] + + +def test_tick_circuit_pass_bindings_simplify_single_qubit_clifford_chains(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + tc.simplify_single_qubit_clifford_chains() + + assert _gate_names(tc) == ["F"] + + +def test_normalize_traced_qis_tick_circuit_simplifies_single_qubit_clifford_chains(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + normalize_traced_qis_tick_circuit(tc, context="test one-qubit Clifford normalization") + + assert _gate_names(tc) == ["F"] + + +def test_normalize_traced_qis_tick_circuit_can_skip_single_qubit_clifford_simplification(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + normalize_traced_qis_tick_circuit( + tc, + context="test one-qubit Clifford normalization", + simplify_single_qubit_clifford_chains=False, + ) + + assert _gate_names(tc) == ["SX", "SZ"] + + def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) - result = sim_neo(tc).quantum(meas_sampling()).noise(noise).shots(3).seed(123).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(3)).seed(123).run() assert result.num_shots == 3 assert result.num_measurements == 2 @@ -239,7 +343,7 @@ def test_sim_neo_native_backends_accept_face_gates(): tc.set_meta("observables", "[]") for backend in (stabilizer(), statevec()): - result = sim_neo(tc).quantum(backend).noise(zero_noise()).shots(2).seed(123).run() + result = sim_neo(tc).quantum(backend).noise(zero_noise()).sampling(monte_carlo(2)).seed(123).run() assert result.num_measurements == 1 assert all(result[shot][0] == 0 for shot in range(result.num_shots)) @@ -280,7 +384,7 @@ def test_random_mirrored_standard_clifford_circuits_match_across_backends(): backend_results = {} for name, backend in (("stabilizer", stabilizer()), ("statevec", statevec())): - result = sim_neo(tc).quantum(backend).noise(zero_noise()).shots(4).seed(seed).run() + result = sim_neo(tc).quantum(backend).noise(zero_noise()).sampling(monte_carlo(4)).seed(seed).run() backend_results[name] = [list(row) for row in result.to_list()] backend_results["StabMps"] = [run_direct_wrapper_mirrored_circuit(StabMps(3, seed=seed), sequence)] diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py index 3b194a7dd..3b6b55f55 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py @@ -12,7 +12,7 @@ from pecos.qec.surface.circuit_builder import tick_circuit_to_stim from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model from pecos_rslib.qec import DemSampler -from pecos_rslib_exp import depolarizing, fault_catalog, meas_sampling, sim_neo +from pecos_rslib_exp import depolarizing, fault_catalog, meas_sampling, monte_carlo, sim_neo pymatching = pytest.importorskip("pymatching") stim = pytest.importorskip("stim") @@ -137,7 +137,12 @@ def test_traced_qis_meas_sampling_ler_tracks_native_dem_pymatching(distance, rou matching = _pymatching_decoder(circuit, noise_args) raw_result = ( - sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).shots(shots).seed(1234).run() + sim_neo(circuit) + .quantum(meas_sampling()) + .noise(_depolarizing_noise(noise_args)) + .sampling(monte_carlo(shots)) + .seed(1234) + .run() ) meas_errors = _decode_raw_measurements(raw_result, circuit, matching, shots) native_errors = _decode_native_dem_samples(circuit, noise_args, matching, shots, seed=5678) @@ -154,7 +159,12 @@ def test_d3_traced_qis_zero_noise_pymatching_pipeline_has_no_logical_errors(): shots = 64 raw_result = ( - sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).shots(shots).seed(2468).run() + sim_neo(circuit) + .quantum(meas_sampling()) + .noise(_depolarizing_noise(noise_args)) + .sampling(monte_carlo(shots)) + .seed(2468) + .run() ) meas_errors = _decode_raw_measurements(raw_result, circuit, matching, shots) native_errors = _decode_native_dem_samples(circuit, noise_args, matching, shots, seed=1357) diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py new file mode 100644 index 000000000..80cfaf70e --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -0,0 +1,182 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Wide (>64) observable support for ``LogicalSubgraphDecoder``. + +Observable flips are packed into a mask; historically a ``u64`` capped decoders +at 64 observables. These tests exercise the wide ``ObsMask`` path: construction, +per-shot ``decode``/``decode_batch`` returning arbitrary-precision Python ints, +and ``decode_count`` comparing wide masks end-to-end. +""" + +from __future__ import annotations + +import pytest +from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem, SampleBatch + + +def _wide_dem(n: int) -> tuple[str, list[list[int]]]: + """A DEM with ``n`` observables, each on its own detector ``D_k``/``L_k``.""" + dem = "".join(f"detector({k},0,0) D{k}\n" for k in range(n)) + "".join(f"error(0.1) D{k} L{k}\n" for k in range(n)) + membership = [[k] for k in range(n)] + return dem, membership + + +def test_decode_returns_big_int_above_64_observables() -> None: + n = 65 + dem, membership = _wide_dem(n) + dec = LogicalSubgraphDecoder.from_membership(dem, membership, "pecos_uf:fast") + assert dec.num_observables() == n + + # Flipping detector 64 sets observable bit 64 -- not representable in a u64. + syn = [0] * n + syn[64] = 1 + result = dec.decode(syn) + assert isinstance(result, int) + assert (result >> 64) & 1 == 1 + assert result == 1 << 64 + + # Two flips, one beyond bit 63. + syn2 = [0] * n + syn2[0] = 1 + syn2[64] = 1 + assert dec.decode(syn2) == (1 << 64) | 1 + + +def test_decode_batch_above_64_observables() -> None: + n = 65 + dem, membership = _wide_dem(n) + dec = LogicalSubgraphDecoder.from_membership(dem, membership, "pecos_uf:fast") + + syndromes = [[0] * n, [0] * n] + syndromes[0][64] = 1 + syndromes[1][0] = 1 + results = dec.decode_batch(syndromes) + assert results[0] == 1 << 64 + assert results[1] == 1 + + +def test_sample_batch_big_int_truth_masks() -> None: + # The SampleBatch constructor accepts arbitrary-precision Python ints as + # observable masks (bit 64 = observable 64), and decode_count compares the + # wide truth mask against the wide prediction with no truncation. + n = 65 + dem, membership = _wide_dem(n) + dec = LogicalSubgraphDecoder.from_membership(dem, membership, "pecos_uf:fast") + + # Two shots, each flipping detector 64 (so the decoder predicts observable 64). + syn = [0] * n + syn[64] = 1 + detection_events = [syn, syn] + + # Truth that MATCHES the prediction (obs 64) -> zero logical errors. + matching = SampleBatch(detection_events, [1 << 64, 1 << 64]) + assert dec.decode_count(matching) == 0 + + # Truth that MISMATCHES (obs 0, not 64) -> both shots are errors. + mismatching = SampleBatch(detection_events, [1, 1]) + assert dec.decode_count(mismatching) == 2 + + +def test_decode_count_above_64_observables() -> None: + # The wide compare path (predicted vs truth ObsMask) must run end-to-end on a + # >64-observable batch without erroring or truncating. + n = 65 + dem, membership = _wide_dem(n) + dec = LogicalSubgraphDecoder.from_membership(dem, membership, "pecos_uf:fast") + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(2000, seed=1) + count = dec.decode_count(batch) + assert 0 <= count <= 2000 + + +def test_u64_observable_getter_rejects_wide_batch() -> None: + # get_observable_mask returns a u64 and cannot represent observable >= 64, so + # it rejects a wide batch; get_observable_mask_wide returns the full Python + # int. (The decode methods, by contrast, compare wide ObsMasks and do not + # reject -- see below.) + n = 65 + _dem, _ = _wide_dem(n) + syn = [0] * n + wide = SampleBatch([syn, syn], [1 << 64, 1 << 64]) + + with pytest.raises(ValueError, match="64-observable"): + wide.get_observable_mask(0) + assert wide.get_observable_mask_wide(0) == 1 << 64 + + +def test_sample_batch_decode_count_batch_handles_wide_dem() -> None: + # decode_count_batch builds wide ObsMask predictions from PyMatching's batch + # output, so a >64-observable DEM is decoded and compared with no truncation + # or panic (no `1 << j` overflow). + n = 70 + dem, _ = _wide_dem(n) + syn = [0] * n + syn[69] = 1 # detector 69 fires => boundary error flips observable 69 + batch = SampleBatch([syn, syn], [1 << 69, 1 << 69]) # truth: observable 69 set + assert batch.decode_count_batch(dem) == 0 + + +def test_generic_decode_handles_wide_dem() -> None: + # With wide inner decoders (PyMatching now packs an ObsMask directly), the + # generic decode_each / decode_count handle a >64-observable DEM end-to-end: + # predictions are wide Python ints with no truncation or panic. + n = 70 + dem, _ = _wide_dem(n) + syn = [0] * n + syn[69] = 1 # detector 69 => boundary error flips observable 69 + batch = SampleBatch([syn, syn], [1 << 69, 1 << 69]) # truth: observable 69 set + + preds = batch.decode_each(dem, "pymatching") + assert preds == [1 << 69, 1 << 69] # observable 69 predicted, not truncated + assert batch.decode_count(dem, "pymatching") == 0 # predictions match truth + assert batch.decode_count_parallel(dem, "pymatching") == 0 + + +def test_decode_each_returns_python_ints() -> None: + # decode_each returns Python ints (arbitrary precision) rather than u64, so + # the value is not truncated; for the <=64 case it equals the historical u64. + n = 5 + dem, _ = _wide_dem(n) + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(8, seed=1) + preds = batch.decode_each(dem, "pymatching") + assert len(preds) == 8 + assert all(isinstance(p, int) for p in preds) + + +@pytest.mark.parametrize("decoder_type", ["fusion_blossom_serial", "pecos_uf", "k_mwpm"]) +def test_matching_decoders_fail_loud_on_wide_dem(decoder_type: str) -> None: + # The graphlike matching decoders pack observables into a u64 (1 << index), so + # they support at most 64 observables. On a >64-observable DEM they must raise a + # clean RuntimeError directing to a wide decoder -- NOT overflow-panic. (A panic + # surfaces as a pyo3 PanicException, a BaseException that pytest.raises(RuntimeError) + # would not catch, so this test fails if the guard regresses to a panic.) + n = 70 + dem, _ = _wide_dem(n) + syn = [0] * n + syn[69] = 1 + batch = SampleBatch([syn, syn], [1 << 69, 1 << 69]) + with pytest.raises(RuntimeError, match="64"): + batch.decode_count(dem, decoder_type) + + +def test_fusion_blossom_direct_constructors_reject_wide() -> None: + # The FusionBlossom Python constructors (from_check_matrix, manual builder) sit + # outside the decoder_type sweep, so they need their own coverage: an observable + # index >= 64 must fail loud rather than overflow-panic when the u64 mask is packed. + import numpy as np + from pecos_rslib.decoders import FusionBlossomDecoder + + h = np.zeros((1, 65), dtype=np.uint8) + h[0, 64] = 1 # observable (column) 64 -> would overflow `1 << 64` + with pytest.raises(RuntimeError, match="64"): + FusionBlossomDecoder.from_check_matrix(h) + + d = FusionBlossomDecoder(num_nodes=2, num_observables=65) + with pytest.raises(RuntimeError, match="64"): + d.add_edge(0, 1, [64], 1.0) + with pytest.raises(RuntimeError, match="64"): + d.add_boundary_edge(0, [64], 1.0) + + # The 64-observable boundary (indices 0..63) must still construct fine. + ok = FusionBlossomDecoder(num_nodes=2, num_observables=64) + ok.add_edge(0, 1, [63], 1.0) diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/conftest.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/conftest.py deleted file mode 100644 index 480179312..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/conftest.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Pytest fixtures for SLR tests.""" - -import pytest -from pecos.slr import CReg, Main, Permute, QReg -from pecos.slr.qeclib import qubit - - -@pytest.fixture -def basic_permutation_program() -> tuple: - """Create a basic program with permutation of classical registers.""" - a = CReg("a", 2) - b = CReg("b", 2) - - prog = Main( - a, - b, - Permute( - [a[0], b[1]], - [b[1], a[0]], - ), - a[0].set(1), # Should become b[1] = 1 after permutation - ) - - return prog, a, b - - -@pytest.fixture -def same_register_permutation_program() -> tuple: - """Create a program with permutation within the same register.""" - a = CReg("a", 3) - - prog = Main( - a, - Permute( - [a[0], a[1], a[2]], - [a[2], a[0], a[1]], - ), - a[0].set(1), # Should become a[2] = 1 - a[1].set(0), # Should become a[0] = 0 - a[2].set(1), # Should become a[1] = 1 - ) - - return prog, a - - -@pytest.fixture -def quantum_permutation_program() -> tuple: - """Create a program with permutation of quantum registers.""" - a = QReg("a", 2) - b = QReg("b", 2) - - prog = Main( - a, - b, - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - qubit.H(a[0]), # Should become H(b[0]) after permutation - qubit.CX(a[0], a[1]), # Should become CX(b[0], a[1]) after permutation - ) - - return prog, a, b - - -@pytest.fixture -def measurement_program() -> tuple: - """Create a program with permutation and measurements.""" - a = QReg("a", 2) - b = QReg("b", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - m, - n, - # Apply permutations to both quantum and classical registers - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - Permute( - [m[0], n[0]], - [n[0], m[0]], - ), - # Apply quantum operations - qubit.H(a[0]), - qubit.CX(a[0], b[0]), - ) - - return prog, a, b, m, n - - -@pytest.fixture -def individual_measurement_program() -> tuple: - """Create a program with permutation and individual measurements.""" - a = QReg("a", 2) - b = QReg("b", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - m, - n, - # Apply permutations to both quantum and classical registers - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - Permute( - [m[0], n[0]], - [n[0], m[0]], - ), - # Apply quantum operations - qubit.H(a[0]), - qubit.CX(a[0], b[0]), - # Add individual measurements - qubit.Measure(a[0]) > m[0], - qubit.Measure(a[1]) > m[1], - ) - - return prog, a, b, m, n - - -@pytest.fixture -def register_measurement_program() -> tuple: - """Create a program with permutation and register-wide measurements.""" - a = QReg("a", 2) - b = QReg("b", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - m, - n, - # Apply permutations to both quantum and classical registers - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - Permute( - [m[0], n[0]], - [n[0], m[0]], - ), - # Apply quantum operations - qubit.H(a[0]), - qubit.CX(a[0], b[0]), - # Add register-wide measurement - qubit.Measure(a) > m, - ) - - return prog, a, b, m, n diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_basic_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_basic_permutation.py deleted file mode 100644 index 627caba05..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_basic_permutation.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Tests for basic permutation functionality in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import CReg, Main, Permute, SlrConverter - -# Test fixtures - - -def create_basic_permutation_program() -> tuple: - """Create a basic program with permutation of classical registers.""" - a = CReg("a", 2) - b = CReg("b", 2) - - prog = Main( - a, - b, - Permute( - [a[0], b[1]], - [b[1], a[0]], - ), - a[0].set(1), # Should become b[1] = 1 after permutation - ) - - return prog, a, b - - -def create_same_register_permutation_program() -> tuple: - """Create a program with permutation within the same register.""" - a = CReg("a", 3) - - prog = Main( - a, - Permute( - [a[0], a[1], a[2]], - [a[2], a[0], a[1]], - ), - a[0].set(1), # Should become a[2] = 1 - a[1].set(0), # Should become a[0] = 0 - a[2].set(1), # Should become a[1] = 1 - ) - - return prog, a - - -# QASM Tests - - -def test_permutation_consistency_for_bits_in_qasm() -> None: - """Test that permutation is consistent across multiple QASM generations.""" - prog = Main( - a := CReg("a", 2), - b := CReg("b", 2), - Permute( - [a[0], b[1]], - [b[1], a[0]], - ), - a[0].set(1), - ) - - qasm1 = SlrConverter(prog).qasm() - qasm2 = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm1) - - assert qasm1 == qasm2 - assert "a[0] = 1;" in qasm1 - - # Verify that the bit permutation is using the temporary bit approach, not XOR swap - assert "creg _bit_swap[1];" in qasm1 - assert "_bit_swap[0] = a[0];" in qasm1 - assert "a[0] = b[1];" in qasm1 - assert "b[1] = _bit_swap[0];" in qasm1 - assert "a[0] = a[0] ^ b[1];" not in qasm1 # Make sure XOR swap is not used - - -def test_basic_permutation_qasm(basic_permutation_program: tuple) -> None: - """Test basic permutation functionality in QASM generation.""" - prog, _, _ = basic_permutation_program - - # Generate QASM - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Verify that the QASM contains the correct permuted operation - # For classical bit permutations, operations still refer to the original bit names - assert "a[0] = 1;" in qasm - - # Verify that the bit permutation is using the temporary bit approach, not XOR swap - assert "creg _bit_swap[1];" in qasm - assert "_bit_swap[0] = a[0];" in qasm - assert "a[0] = b[1];" in qasm - assert "b[1] = _bit_swap[0];" in qasm - assert "a[0] = a[0] ^ b[1];" not in qasm # Make sure XOR swap is not used - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -def test_same_register_permutation_qasm( - same_register_permutation_program: tuple, -) -> None: - """Test permutation of elements within the same register in QASM.""" - prog, _ = same_register_permutation_program - - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # For classical bit permutations, operations still refer to the original bit names - assert "a[0] = 1;" in qasm - assert "a[1] = 0;" in qasm - assert "a[2] = 1;" in qasm - - # Verify that the bit permutation is using the temporary bit approach, not XOR swap - assert "creg _bit_swap[1];" in qasm - assert "_bit_swap[0] = a[0];" in qasm - assert "a[0] = a[2];" in qasm # Part of the cycle - assert "a[2] = a[1];" in qasm # Part of the cycle - assert "a[1] = _bit_swap[0];" in qasm # Completing the cycle - assert "a[0] = a[0] ^ a[1];" not in qasm # Make sure XOR swap is not used - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_basic_permutation_qir(basic_permutation_program: tuple) -> None: - """Test basic permutation functionality in QIR generation.""" - prog, _, _ = basic_permutation_program - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a[0] -> b[1], b[1] -> a[0]" in qir - - # Extract the register and index used in the set_creg_bit call - # This should be setting a[0] (register %a, index 0) since the permutation - # is not being applied to the operations in the QIR generator - set_creg_calls = re.findall( - r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 1\)", - qir, - ) - - # We should have at least one set_creg_bit call - assert len(set_creg_calls) >= 1, "No set_creg_bit call found" - - # Get the register and index - reg_name, index = set_creg_calls[0] - - # Verify that the set_creg_bit call is setting a[0] since the permutation - # is not being applied to the operations in the QIR generator - assert reg_name == "a", f"set_creg_bit applied to register {reg_name}, expected a" - assert index == "0", f"set_creg_bit applied to index {index}, expected 0" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_same_register_permutation_qir( - same_register_permutation_program: tuple, -) -> None: - """Test permutation of elements within the same register in QIR.""" - prog, _ = same_register_permutation_program - - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a[0] -> a[2], a[1] -> a[0], a[2] -> a[1]" in qir - - # Extract the register and indices used in the set_creg_bit calls - set_creg_calls = re.findall( - r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 (\d+)\)", - qir, - ) - - # We should have at least three set_creg_bit calls - assert len(set_creg_calls) >= 3, f"Expected at least 3 set_creg_bit calls, found {len(set_creg_calls)}" - - # Create a dictionary to store the values set for each index - set_values = {} - for reg_name, index, value in set_creg_calls: - assert reg_name == "a", f"set_creg_bit applied to register {reg_name}, expected a" - set_values[int(index)] = int(value) - - # Verify that the set_creg_bit calls are setting the correct values - # Since the permutation is not being applied to the operations in the QIR generator, - # we expect the original operations to be executed - assert set_values.get(0) == 1, "a[0] should be set to 1" - assert set_values.get(1) == 0, "a[1] should be set to 0" - assert set_values.get(2) == 1, "a[2] should be set to 1" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_complex_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_complex_permutation.py deleted file mode 100644 index 9a14a3fb4..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_complex_permutation.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Tests for complex permutation scenarios in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import CReg, If, Main, Permute, QReg, SlrConverter -from pecos.slr.qeclib import qubit - -# QASM Tests - - -def test_complex_permutation_circuit() -> None: - """Test a more complex circuit with multiple permutations at different stages.""" - prog = Main( - a := QReg("a", 3), - b := QReg("b", 3), - c := QReg("c", 3), - # Initial operations - Layer 1 - qubit.H(a[0]), # Hadamard on a[0] - qubit.X(b[1]), # X gate on b[1] - qubit.Z(c[2]), # Z gate on c[2] - # First permutation: rotate registers - Permute( - [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]], - [b[0], b[1], b[2], c[0], c[1], c[2], a[0], a[1], a[2]], - ), - # Operations after first permutation - Layer 2 - # a[0] -> b[0], b[1] -> c[1], c[2] -> a[2] - qubit.X(a[0]), # X gate on a[0] -> should become X on b[0] - qubit.Y(b[1]), # Y gate on b[1] -> should become Y on c[1] - qubit.Z(c[2]), # Z gate on c[2] -> should become Z on a[2] - ) - - qasm = SlrConverter(prog).qasm() - - # Check that the permutation was applied correctly - assert "h a[0];" in qasm.lower() # Initial operation - assert "x b[1];" in qasm.lower() # Initial operation - assert "z c[2];" in qasm.lower() # Initial operation - - assert "x b[0];" in qasm.lower() # After first permutation - assert "y c[1];" in qasm.lower() # After first permutation - assert "z a[2];" in qasm.lower() # After first permutation - - -def test_multiple_permutations_qasm() -> None: - """Test multiple sequential permutations in QASM generation.""" - # Create a program with multiple sequential permutations - a = QReg("a", 3) - b = QReg("b", 3) - - prog = Main( - a, - b, - # First permutation - Permute( - [a[0], a[1]], - [a[1], a[0]], - ), - # Apply an operation - qubit.H(a[0]), # Should become H(a[1]) after first permutation - # Second permutation - Permute( - [a[1], b[0]], - [b[0], a[1]], - ), - # Apply another operation - qubit.H(a[0]), # Should still be H(a[1]) after first permutation only - qubit.X(a[1]), # Should become X(b[0]) after both permutations - ) - - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM Output:") - # print(qasm) - - # Verify that the QASM contains the correct permuted operations - assert "h a[1];" in qasm # First H gate - # The second H gate is applied to a[0] which is mapped to b[0] after both permutations - assert "h b[0];" in qasm # Second H gate - # The X gate is applied to a[1] which is mapped to a[0] after both permutations - assert "x a[0];" in qasm # X gate after both permutations - - -def test_permutation_with_conditional_qasm() -> None: - """Test permutation with conditional operations in QASM generation.""" - # Create a program with permutation and conditional operations - a = QReg("a", 2) - b = CReg("b", 2) - - prog = Main( - a, - b, - # Set a classical bit - b[0].set(1), - # Apply a permutation - Permute( - [a[0], a[1], b[0], b[1]], - [a[1], a[0], b[1], b[0]], - ), - # Apply a conditional operation - # After permutation: b[0] -> b[1], a[0] -> a[1] - # So the condition should be on b[1] and the operation should be on a[1] - If(b[0] == 1).Then(qubit.X(a[0])), - ) - - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM Output:") - # print(qasm) - - # Verify that the QASM contains the correct permuted operations - assert "b[0] = 1;" in qasm # The classical bit assignment happens before permutation - # The condition and operation should both be permuted - assert "if(b[1] == 1) x a[1];" in qasm - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_multiple_permutations_qir() -> None: - """Test multiple sequential permutations in QIR generation.""" - # Create a program with multiple sequential permutations - a = QReg("a", 3) - b = QReg("b", 3) - - prog = Main( - a, - b, - # First permutation - Permute( - [a[0], a[1]], - [a[1], a[0]], - ), - # Apply an operation - qubit.H(a[0]), # Should become H(a[1]) after first permutation - # Second permutation - Permute( - [a[1], b[0]], - [b[0], a[1]], - ), - # Apply another operation - qubit.H(a[0]), # Should still be H(a[1]) after first permutation only - qubit.X(a[1]), # Should become X(b[0]) after both permutations - ) - - qir = SlrConverter(prog).qir() - - # Verify that the QIR contains comments about the permutations - assert "Permutation: a[0] -> a[1], a[1] -> a[0]" in qir - assert "Permutation: a[1] -> b[0], b[0] -> a[1]" in qir - - # Extract the quantum operations - h_calls = re.findall( - r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - x_calls = re.findall( - r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # We should have at least two H calls and one X call - assert len(h_calls) >= 2, f"Expected at least 2 H gate calls, found {len(h_calls)}" - assert len(x_calls) >= 1, f"Expected at least 1 X gate call, found {len(x_calls)}" - - -@pytest.mark.optional_dependency -def test_permutation_with_conditional_qir() -> None: - """Test permutation with conditional operations in QIR generation.""" - # Create a program with permutation and conditional operations - a = QReg("a", 2) - b = CReg("b", 2) - - prog = Main( - a, - b, - # Set a classical bit - b[0].set(1), - # Apply a permutation - Permute( - [a[0], a[1], b[0], b[1]], - [a[1], a[0], b[1], b[0]], - ), - # Apply a conditional operation - # After permutation: b[0] -> b[1], a[0] -> a[1] - # So the condition should be on b[1] and the operation should be on a[1] - If(b[0] == 1).Then(qubit.X(a[0])), - ) - - qir = SlrConverter(prog).qir() - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a[0] -> a[1], a[1] -> a[0], b[0] -> b[1], b[1] -> b[0]" in qir - - # Extract the set_creg_bit call - set_creg_calls = re.findall( - r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 1\)", - qir, - ) - - # We should have at least one set_creg_bit call - assert len(set_creg_calls) >= 1, "No set_creg_bit call found" - - # Get the register and index - reg_name, index = set_creg_calls[0] - - # Verify that the set_creg_bit call is setting b[0] (not permuted, as it happens before the permutation) - assert reg_name == "b", f"set_creg_bit applied to register {reg_name}, expected b" - assert index == "0", f"set_creg_bit applied to index {index}, expected 0" - - # Extract the conditional X operation - # In QIR, conditionals use __quantum__rt__array_get_element_ptr_1d to access the condition - # and then branch based on the condition - # This is a simplified check that just verifies an X gate is called somewhere after the condition check - x_calls = re.findall( - r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # We should have at least one X call - assert len(x_calls) >= 1, "No X gate call found" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_creg_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_creg_permutation.py deleted file mode 100644 index a3fc4b527..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_creg_permutation.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for classical register permutation functionality.""" - -import re - -import pecos.slr -import pytest -from pecos.slr.slr_converter import SlrConverter - - -def create_creg_permutation_program() -> tuple: - """Create a program with permutation of whole classical registers followed by both bit and register operations.""" - a = pecos.slr.CReg("a", size=1) - b = pecos.slr.CReg("b", size=1) - - return pecos.slr.Main( - a, - b, - pecos.slr.Permute(a, b), - a[0].set(1), # Bit-level operation - a.set(1), # Register-level operation - ) - - -def test_creg_permutation_qasm() -> None: - """Test permutation of whole classical registers followed by both bit and register operations in QASM.""" - prog = create_creg_permutation_program() - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Verify the XOR swap operations are generated - assert "a = a ^ b;" in qasm, f"Expected 'a = a ^ b;' not found in QASM:\n{qasm}" - assert "b = b ^ a;" in qasm, f"Expected 'b = b ^ a;' not found in QASM:\n{qasm}" - assert "a = a ^ b;" in qasm, f"Expected 'a = a ^ b;' not found in QASM:\n{qasm}" - - # Verify the temporary bit approach is NOT used for whole register permutations - assert "creg _bit_swap[1];" not in qasm, f"Unexpected 'creg _bit_swap[1];' found in QASM:\n{qasm}" - - # Verify the permutation comment is correct - assert "// Permutation: a <-> b" in qasm, f"Expected permutation comment not found in QASM:\n{qasm}" - - # Verify the operations after the permutation - # For classical bit permutations, we're physically moving the values, - # Since we're not updating the permutation map for classical register permutations, - # both bit-level and register-level operations should still refer to the original registers. - assert "a[0] = 1;" in qasm, f"Expected 'a[0] = 1;' not found in QASM:\n{qasm}" - assert "a = 1;" in qasm, f"Expected 'a = 1;' not found in QASM:\n{qasm}" - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_creg_permutation_qir() -> None: - """Test permutation of whole classical registers followed by both bit and register operations in QIR.""" - prog = create_creg_permutation_program() - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a <-> b" in qir, "Expected permutation comment not found in QIR" - - # Verify that the XOR operations are present - assert "xor" in qir, "Expected XOR operations not found in QIR" - - # Verify the temporary bit approach is NOT used for whole register permutations - assert ( - "_bit_swap = call i1* @create_creg(i64 1)" not in qir - ), "Unexpected '_bit_swap = call i1* @create_creg(i64 1)' found in QIR" - - # Extract the register and index used in the set_creg_bit call for the bit-level operation - set_creg_bit_calls = re.findall( - r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 1\)", - qir, - ) - assert len(set_creg_bit_calls) >= 1, "No set_creg_bit call found for bit-level operation" - - # Get the register and index for the bit-level operation - reg_name, index = set_creg_bit_calls[0] - - # In QIR, unlike QASM, the permutation is not applied to bit-level operations - # So a[0].set(1) still refers to register a, index 0 - assert reg_name == "a", f"set_creg_bit applied to register {reg_name}, expected a" - assert index == "0", f"set_creg_bit applied to index {index}, expected 0" - - # Extract the register used in the set_creg call for the register-level operation - set_creg_calls = re.findall( - r"call void @set_creg_to_int\(i1\* %(\w+), i64 1\)", - qir, - ) - assert len(set_creg_calls) >= 1, "No set_creg_to_int call found for register-level operation" - - # Get the register for the register-level operation - reg_name = set_creg_calls[0] - - # For register-level operations, the original register name is used - # So a.set(1) still refers to register a - assert reg_name == "a", f"set_creg_to_int applied to register {reg_name}, expected a" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_permutation.py deleted file mode 100644 index 2a3bd73aa..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_permutation.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests for measurement with permutation functionality in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import SlrConverter - -# QASM Tests - - -def test_individual_measurement_permutation_qasm( - individual_measurement_program: tuple, -) -> None: - """Test individual measurements with permutations in QASM generation.""" - prog, _, _, _, _ = individual_measurement_program - - # Generate QASM - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Verify that the QASM contains the correct permuted measurements - # After permutation: a[0] -> b[0], m[0] -> n[0] - # For classical bit permutations, operations still refer to the original bit names - # For quantum registers, we still use the permutation map approach - assert "measure b[0] -> m[0];" in qasm - assert "measure a[1] -> m[1];" in qasm - - # Verify that the bit permutation is using the temporary bit approach, not XOR swap - assert "creg _bit_swap[1];" in qasm - assert "_bit_swap[0] = m[0];" in qasm - assert "m[0] = n[0];" in qasm - assert "n[0] = _bit_swap[0];" in qasm - assert "m[0] = m[0] ^ n[0];" not in qasm # Make sure XOR swap is not used - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -def test_register_measurement_permutation_qasm( - register_measurement_program: tuple, -) -> None: - """Test register-wide measurements with permutations in QASM generation.""" - prog, _, _, _, _ = register_measurement_program - - # Generate QASM - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Register-wide measurements are now unrolled correctly with permutations - # The expected behavior is: - assert "measure b[0] -> m[0];" in qasm, f"Expected 'measure b[0] -> m[0];' not found in QASM:\n{qasm}" - assert "measure a[1] -> m[1];" in qasm, f"Expected 'measure a[1] -> m[1];' not found in QASM:\n{qasm}" - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_individual_measurement_permutation_qir( - individual_measurement_program: tuple, -) -> None: - """Test individual measurements with permutations in QIR generation.""" - prog, _, _, _, _ = individual_measurement_program - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains comments about the permutations - assert "; Permutation: a[0] -> b[0], b[0] -> a[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - assert "; Permutation: m[0] -> n[0], n[0] -> m[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct classical bit permutation using a temporary bit - assert ( - "%_bit_swap = call i1* @create_creg(i64 1)" in qir - ), f"Expected temporary bit creation not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct quantum operations after permutation - # H gate should be applied to b[0] after permutation - h_gate_pattern = r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - h_gates = re.findall(h_gate_pattern, qir) - assert len(h_gates) >= 1, f"Expected at least one H gate, found {len(h_gates)}" - - # CX gate should be applied to b[0] and a[0] after permutation - cx_gate_pattern = ( - r"call void @__quantum__qis__cnot__body\(" - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - ) - cx_gates = re.findall(cx_gate_pattern, qir) - assert len(cx_gates) >= 1, f"Expected at least one CX gate, found {len(cx_gates)}" - - # Extract the measurement operations - # In QIR, measurements are done with mz_to_creg_bit - mz_to_creg_pattern = ( - r"call void @mz_to_creg_bit\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), i1\* %(\w+), i64 (\d+)\)" - ) - mz_to_creg_calls = re.findall(mz_to_creg_pattern, qir) - - # We should have at least two measurement calls (one for each qubit in register a) - assert len(mz_to_creg_calls) >= 2, f"Expected at least 2 measurement calls, found {len(mz_to_creg_calls)}" - - # Create a dictionary to store the measurements - measurements = {} - for qubit_idx, creg_name, creg_idx in mz_to_creg_calls: - if creg_name == "m": - measurements[int(creg_idx)] = (creg_name, int(qubit_idx)) - - # Verify that the correct qubits are measured into the correct classical bits - assert 0 in measurements, f"Expected measurement to m[0], found measurements to {list(measurements.keys())}" - assert 1 in measurements, f"Expected measurement to m[1], found measurements to {list(measurements.keys())}" - - # Verify that different qubits are measured into different classical bits - measured_qubits = [idx for _, idx in measurements.values()] - assert len(set(measured_qubits)) == len( - measured_qubits, - ), f"Expected all measurements to be from different qubits, found duplicates: {measured_qubits}" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_register_measurement_permutation_qir( - register_measurement_program: tuple, -) -> None: - """Test register-wide measurements with permutations in QIR generation.""" - prog, _, _, _, _ = register_measurement_program - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains comments about the permutations - assert "; Permutation: a[0] -> b[0], b[0] -> a[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - assert "; Permutation: m[0] -> n[0], n[0] -> m[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct classical bit permutation using a temporary bit - assert ( - "%_bit_swap = call i1* @create_creg(i64 1)" in qir - ), f"Expected temporary bit creation not found in QIR:\n{qir}" - assert ( - "call void @set_creg_bit(i1* %_bit_swap, i64 0, i1 %.4)" in qir - ), f"Expected temporary bit assignment not found in QIR:\n{qir}" - assert "call void @set_creg_bit(i1* %m, i64 0, i1 %.6)" in qir, f"Expected bit assignment not found in QIR:\n{qir}" - assert "call void @set_creg_bit(i1* %n, i64 0, i1 %.8)" in qir, f"Expected bit assignment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct quantum operations after permutation - # H gate should be applied to b[0] (qubit 2) after permutation - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 2 to %Qubit*))" in qir - ), f"Expected H gate on permuted qubit not found in QIR:\n{qir}" - - # CNOT gate should be applied to b[0] (qubit 2) and a[0] (qubit 0) after permutation - assert ( - "call void @__quantum__qis__cnot__body(" - "%Qubit* inttoptr (i64 2 to %Qubit*), %Qubit* inttoptr (i64 0 to %Qubit*))" in qir - ), f"Expected CNOT gate on permuted qubits not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct measurements after permutation - # a[0] should be measured as b[0] (qubit 2) after permutation - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 2 to %Qubit*), i1* %m, i64 0)" in qir - ), f"Expected measurement of permuted qubit not found in QIR:\n{qir}" - - # a[1] should be measured as a[1] (qubit 1) since it's not permuted - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 1 to %Qubit*), i1* %m, i64 1)" in qir - ), f"Expected measurement of non-permuted qubit not found in QIR:\n{qir}" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_unrolling.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_unrolling.py deleted file mode 100644 index becdd8704..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_unrolling.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Tests for measurement unrolling with permutations in both QASM and QIR generation.""" - -import pytest -from pecos.slr import CReg, Main, Permute, QReg, SlrConverter -from pecos.slr.qeclib import qubit - - -def create_measurement_unrolling_program() -> tuple: - """Create a program with permutations and register-wide measurements.""" - a = QReg("a", 3) - b = QReg("b", 3) - c = QReg("c", 3) - m = CReg("m", 3) - - return Main( - a, - b, - c, - m, - # Initial gates - qubit.H(a), - qubit.X(b[1]), - # First permutation - Permute( - [a[0], b[1], c[2]], - [c[2], a[0], b[1]], - ), - # Gates after first permutation - qubit.CX(a[0], b[0]), # Should be CX(c[2], b[0]) - qubit.Z(b[1]), # Should be Z(a[0]) - # Second permutation - Permute(a, c), - # Gates after second permutation - qubit.H(a[1]), # Should be H(c[1]) - qubit.CX(c[0], b[2]), # Should be CX(a[0], b[2]) - # Register-wide measurement - should be unrolled correctly - qubit.Measure(a) > m, - ) - - -def test_measurement_unrolling_qasm() -> None: - """Test measurement unrolling with permutations in QASM generation.""" - prog = create_measurement_unrolling_program() - - # Print the program structure for debugging - # print("\nProgram structure:") - # print(f"Operations: {[type(op).__name__ for op in prog.ops]}") - - # Get the last operation (should be the Measure operation) - prog.ops[-1] - # print(f"\nMeasure operation: {type(measure_op).__name__}") - # print(f"qargs: {measure_op.qargs}") - # print(f"cout: {measure_op.cout}") - - # Generate QASM using SlrConverter - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Verify that the register-wide measurement is unrolled correctly - # After permutation composition: - # First perm: a[0] -> c[2], b[1] -> a[0], c[2] -> b[1] - # Second perm (a <-> c swap): compose with first - # Result: a[0] -> a[2], a[1] -> c[1], a[2] -> c[2] - assert "measure a[2] -> m[0];" in qasm, f"Expected 'measure a[2] -> m[0];' not found in QASM:\n{qasm}" - assert "measure c[1] -> m[1];" in qasm, f"Expected 'measure c[1] -> m[1];' not found in QASM:\n{qasm}" - assert "measure c[2] -> m[2];" in qasm, f"Expected 'measure c[2] -> m[2];' not found in QASM:\n{qasm}" - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_measurement_unrolling_qir() -> None: - """Test measurement unrolling with permutations in QIR generation.""" - prog = create_measurement_unrolling_program() - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains comments about the permutations - assert ( - "; Permutation: a[0] -> c[2], b[1] -> a[0], c[2] -> b[1]" in qir - ), f"Expected permutation comment not found in QIR:\n{qir}" - assert "; Permutation: a <-> c" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct quantum operations after permutations - # H gates should be applied to a[0], a[1], a[2] (qubits 0, 1, 2) initially - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 0 to %Qubit*))" in qir - ), f"Expected H gate on a[0] not found in QIR:\n{qir}" - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))" in qir - ), f"Expected H gate on a[1] not found in QIR:\n{qir}" - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 2 to %Qubit*))" in qir - ), f"Expected H gate on a[2] not found in QIR:\n{qir}" - - # X gate should be applied to b[1] (qubit 4) initially - assert ( - "call void @__quantum__qis__x__body(%Qubit* inttoptr (i64 4 to %Qubit*))" in qir - ), f"Expected X gate on b[1] not found in QIR:\n{qir}" - - # After first permutation: - # CNOT gate should be applied to c[2] (qubit 8) and b[0] (qubit 3) - assert ( - "call void @__quantum__qis__cnot__body(" - "%Qubit* inttoptr (i64 8 to %Qubit*), %Qubit* inttoptr (i64 3 to %Qubit*))" in qir - ), f"Expected CNOT gate on permuted qubits not found in QIR:\n{qir}" - - # Z gate should be applied to a[0] (qubit 0) after first permutation - assert ( - "call void @__quantum__qis__z__body(%Qubit* inttoptr (i64 0 to %Qubit*))" in qir - ), f"Expected Z gate on permuted qubit not found in QIR:\n{qir}" - - # After second permutation: - # H gate should be applied to c[1] (qubit 7) after both permutations - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 7 to %Qubit*))" in qir - ), f"Expected H gate on permuted qubit not found in QIR:\n{qir}" - - # CNOT gate should be applied to a[0] (qubit 0) and b[2] (qubit 5) after both permutations - assert ( - "call void @__quantum__qis__cnot__body(" - "%Qubit* inttoptr (i64 0 to %Qubit*), %Qubit* inttoptr (i64 5 to %Qubit*))" in qir - ), f"Expected CNOT gate on permuted qubits not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct measurements after permutations - # Register-wide measurement of a should be unrolled to individual measurements - # a[0] should be measured as c[0] (qubit 2) after both permutations - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 2 to %Qubit*), i1* %m, i64 0)" in qir - ), f"Expected measurement of a[0] as c[0] not found in QIR:\n{qir}" - - # a[1] should be measured as c[1] (qubit 7) after both permutations - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 7 to %Qubit*), i1* %m, i64 1)" in qir - ), f"Expected measurement of a[1] as c[1] not found in QIR:\n{qir}" - - # a[2] should be measured as c[2] (qubit 8) after both permutations - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 8 to %Qubit*), i1* %m, i64 2)" in qir - ), f"Expected measurement of a[2] as c[2] not found in QIR:\n{qir}" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_quantum_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_quantum_permutation.py deleted file mode 100644 index 755e607a5..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_quantum_permutation.py +++ /dev/null @@ -1,453 +0,0 @@ -"""Tests for quantum permutation functionality in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import CReg, Main, Permute, QReg, SlrConverter, rad -from pecos.slr.qeclib import qubit - -# QASM Tests - - -def test_permutation_consistency_with_multiple_calls() -> None: - """Test that multiple calls to qasm() produce the same result.""" - prog = Main( - a := QReg("a", 2), - b := QReg("b", 2), - Permute( - [a[0], a[1], b[0], b[1]], - [b[0], b[1], a[0], a[1]], - ), - qubit.H(a[0]), # Should become H b[0]; - qubit.X(a[1]), # Should become X b[1]; - qubit.Z(b[0]), # Should become Z a[0]; - qubit.Y(b[1]), # Should become Y a[1]; - ) - - qasm1 = SlrConverter(prog).qasm() - qasm2 = SlrConverter(prog).qasm() - qasm3 = SlrConverter(prog).qasm() - - assert qasm1 == qasm2 - assert qasm2 == qasm3 - - # Check that the permutation was applied correctly - assert "h b[0];" in qasm1.lower() - assert "x b[1];" in qasm1.lower() - assert "z a[0];" in qasm1.lower() - assert "y a[1];" in qasm1.lower() - - -def test_quantum_permutation_qasm(quantum_permutation_program: tuple) -> None: - """Test permutation with quantum gates in QASM generation.""" - prog, _, _ = quantum_permutation_program - - # Generate QASM - qasm = SlrConverter(prog).qasm() - - # Verify that the QASM contains the correct permuted quantum operations - assert "h b[0];" in qasm - assert "cx b[0], a[1];" in qasm - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_quantum_permutation_qir(quantum_permutation_program: tuple) -> None: - """Test permutation with quantum gates in QIR generation.""" - prog, _, _ = quantum_permutation_program - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for analysis - # print("\nQIR Output for quantum_permutation_qir:") - # print(qir) - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a[0] -> b[0], b[0] -> a[0]" in qir - - # Extract the qubit indices used in the H and CNOT operations - h_calls = re.findall( - r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - cnot_calls = re.findall( - r"call void @__quantum__qis__cnot__body\(" - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # print(f"H calls found: {h_calls}") - # print(f"CNOT calls found: {cnot_calls}") - - # We should have at least one H call and one CNOT call - assert len(h_calls) >= 1, "No H gate call found" - assert len(cnot_calls) >= 1, "No CNOT gate call found" - - # Get the qubit indices - h_qubit = int(h_calls[0]) - cnot_control, _cnot_target = map(int, cnot_calls[0]) - - # Verify that the H and CNOT operations are applied to the correct qubits after permutation - # The exact indices will depend on how qubits are allocated in the QIR generator - # We can't assert the exact indices without knowing the allocation strategy - # But we can verify that the CNOT control qubit is the same as the H qubit - assert h_qubit == cnot_control, f"H applied to qubit {h_qubit}, but CNOT control is qubit {cnot_control}" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_permutation_with_bell_circuit_qir() -> None: - """Test permutation functionality with a Bell circuit in QIR generation.""" - # Create a program with permutations and a Bell circuit - a = QReg("a", 2) - b = QReg("b", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - m, - n, - # Permute quantum registers - Permute( - [a[0], b[1]], - [b[1], a[0]], - ), - # Permute classical registers - Permute( - [m[0], n[0]], - [n[0], m[0]], - ), - # Apply H gate to a[0] - should be applied to b[1] after permutation - qubit.H(a[0]), - # Apply CX gate from a[0] to a[1] - should be from b[1] to a[1] after permutation - qubit.CX(a[0], a[1]), - # Measure individual qubits to individual bits - qubit.Measure(a[0]) > m[0], - qubit.Measure(a[1]) > m[1], - ) - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for analysis - # print("\nQIR Output for bell_circuit_qir:") - # print(qir) - - # Verify that the QIR contains comments about the permutations - assert "Permutation: a[0] -> b[1], b[1] -> a[0]" in qir - assert "Permutation: m[0] -> n[0], n[0] -> m[0]" in qir - - # Extract the quantum operations - h_calls = re.findall( - r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - cx_calls = re.findall( - r"call void @__quantum__qis__cnot__body\(" - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # print(f"H calls found: {h_calls}") - # print(f"CX calls found: {cx_calls}") - - # We should have at least one H call and one CX call - assert len(h_calls) >= 1, "No H gate call found" - assert len(cx_calls) >= 1, "No CX gate call found" - - # Extract the measurement operations - mz_calls = re.findall( - r"call %Result\* @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - mz_to_creg_calls = re.findall( - r"call void @mz_to_creg_bit\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), i1\* %(\w+), i64 (\d+)\)", - qir, - ) - - # print(f"MZ calls found: {mz_calls}") - # print(f"MZ to creg calls found: {mz_to_creg_calls}") - - # We should have at least two measurement calls (one for each qubit) - assert len(mz_calls) + len(mz_to_creg_calls) >= 2, ( - f"Expected at least 2 measurement calls, found {len(mz_calls)} mz calls " - f"and {len(mz_to_creg_calls)} mz_to_creg calls" - ) - - -@pytest.mark.optional_dependency -def test_comprehensive_qir_verification() -> None: - """Test comprehensive verification of QIR generation with permutations.""" - # Create a program with a variety of operations to test permutation effects - a = QReg("a", 2) - b = QReg("b", 2) - c = QReg("c", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - c, - m, - n, - # Apply some initial gates to track qubit allocation - qubit.H(a[0]), # Track as "original a[0]" - qubit.X(a[1]), # Track as "original a[1]" - qubit.Y(b[0]), # Track as "original b[0]" - qubit.Z(b[1]), # Track as "original b[1]" - # First permutation: swap a[0] and b[0] - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - # Apply gates after first permutation - qubit.H(a[0]), # Should apply to "original b[0]" - qubit.X(b[0]), # Should apply to "original a[0]" - # Second permutation: swap a[1] and b[1] - Permute( - [a[1], b[1]], - [b[1], a[1]], - ), - # Apply gates after second permutation - qubit.Y(a[1]), # Should apply to "original b[1]" - qubit.Z(b[1]), # Should apply to "original a[1]" - # Apply some two-qubit gates to test cross-register operations - qubit.CX(a[0], b[1]), # Should be CX from "original b[0]" to "original a[1]" - # Measure qubits to classical bits - qubit.Measure(a[0]) > m[0], # Should measure "original b[0]" to m[0] - qubit.Measure(b[1]) > n[0], # Should measure "original a[1]" to n[0] - ) - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for analysis - # print("\nQIR Output for comprehensive_qir_verification:") - # print(qir) - - # Extract all gate operations to track qubit allocation - h_calls = re.findall( - r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - x_calls = re.findall( - r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - y_calls = re.findall( - r"call void @__quantum__qis__y__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - z_calls = re.findall( - r"call void @__quantum__qis__z__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - cx_calls = re.findall( - r"call void @__quantum__qis__cnot__body\(" - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - mz_to_creg_calls = re.findall( - r"call void @mz_to_creg_bit\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), i1\* %(\w+), i64 (\d+)\)", - qir, - ) - - # print(f"H calls: {h_calls}") - # print(f"X calls: {x_calls}") - # print(f"Y calls: {y_calls}") - # print(f"Z calls: {z_calls}") - # print(f"CX calls: {cx_calls}") - # print(f"MZ to creg calls: {mz_to_creg_calls}") - - # Based on the initial gates, we can infer the qubit allocation: - # The first H call should be for "original a[0]" - # The first X call should be for "original a[1]" - # The first Y call should be for "original b[0]" - # The first Z call should be for "original b[1]" - if len(h_calls) >= 1 and len(x_calls) >= 1 and len(y_calls) >= 1 and len(z_calls) >= 1: - original_a0 = int(h_calls[0]) - original_a1 = int(x_calls[0]) - original_b0 = int(y_calls[0]) - original_b1 = int(z_calls[0]) - - # print("Inferred qubit allocation:") - # print(f" original a[0] -> physical qubit {original_a0}") - # print(f" original a[1] -> physical qubit {original_a1}") - # print(f" original b[0] -> physical qubit {original_b0}") - # print(f" original b[1] -> physical qubit {original_b1}") - - # Now we can verify that the gates after permutations are applied to the correct qubits - # The second H call should be for "original b[0]" - # The second X call should be for "original a[0]" - if len(h_calls) >= 2 and len(x_calls) >= 2: - assert int(h_calls[1]) == original_b0, ( - f"Second H gate should be applied to original b[0] " - f"(physical qubit {original_b0}), but was applied to physical qubit {h_calls[1]}" - ) - assert int(x_calls[1]) == original_a0, ( - f"Second X gate should be applied to original a[0] " - f"(physical qubit {original_a0}), but was applied to physical qubit {x_calls[1]}" - ) - - # The second Y call should be for "original b[1]" - # The second Z call should be for "original a[1]" - if len(y_calls) >= 2 and len(z_calls) >= 2: - assert int(y_calls[1]) == original_b1, ( - f"Second Y gate should be applied to original b[1] " - f"(physical qubit {original_b1}), but was applied to physical qubit {y_calls[1]}" - ) - assert int(z_calls[1]) == original_a1, ( - f"Second Z gate should be applied to original a[1] " - f"(physical qubit {original_a1}), but was applied to physical qubit {z_calls[1]}" - ) - - # The CX gate should be from "original b[0]" to "original a[1]" - if len(cx_calls) >= 1: - cx_control, cx_target = map(int, cx_calls[0]) - assert ( - cx_control == original_b0 - ), f"CX control should be original b[0] (physical qubit {original_b0}), but was physical qubit {cx_control}" - assert ( - cx_target == original_a1 - ), f"CX target should be original a[1] (physical qubit {original_a1}), but was physical qubit {cx_target}" - - # The measurements should be from "original b[0]" to m[0] and from "original a[1]" to n[0] - if len(mz_to_creg_calls) >= 2: - mz1_qubit, mz1_reg, mz1_idx = mz_to_creg_calls[0] - mz2_qubit, mz2_reg, mz2_idx = mz_to_creg_calls[1] - - # Check if either measurement matches our expectations - b0_to_m0 = (int(mz1_qubit) == original_b0 and mz1_reg == "m" and int(mz1_idx) == 0) or ( - int(mz2_qubit) == original_b0 and mz2_reg == "m" and int(mz2_idx) == 0 - ) - a1_to_n0 = (int(mz1_qubit) == original_a1 and mz1_reg == "n" and int(mz1_idx) == 0) or ( - int(mz2_qubit) == original_a1 and mz2_reg == "n" and int(mz2_idx) == 0 - ) - - assert b0_to_m0, ( - f"Expected measurement from original b[0] (physical qubit {original_b0}) to m[0], " - f"but found measurements: {mz_to_creg_calls}" - ) - assert a1_to_n0, ( - f"Expected measurement from original a[1] (physical qubit {original_a1}) to n[0], " - f"but found measurements: {mz_to_creg_calls}" - ) - - -@pytest.mark.optional_dependency -def test_rotation_gates_with_permutation() -> None: - """Test that permutations work correctly with rotation gates in QIR generation.""" - # Create a program with rotation gates and permutations - a = QReg("a", 2) - b = QReg("b", 2) - - prog = Main( - a, - b, - # Apply initial gates to track qubit allocation - qubit.RX(rad(0.1), a[0]), # Track as "original a[0]" - qubit.RY(rad(0.2), a[1]), # Track as "original a[1]" - qubit.RZ(rad(0.3), b[0]), # Track as "original b[0]" - qubit.SZ(b[1]), # Track as "original b[1]" - # Apply permutation - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - # Apply gates after permutation - qubit.RX(rad(0.4), a[0]), # Should apply to "original b[0]" - qubit.RY(rad(0.5), b[0]), # Should apply to "original a[0]" - qubit.T(a[1]), # Should apply to "original a[1]" - qubit.Tdg(b[1]), # Should apply to "original b[1]" - ) - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for analysis - # print("\nQIR Output for rotation_gates_with_permutation:") - # print(qir) - - # Extract all gate operations to track qubit allocation - rx_calls = re.findall( - r"call void @__quantum__qis__rx__body\(double (0x[0-9a-f]+), %Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - ry_calls = re.findall( - r"call void @__quantum__qis__ry__body\(double (0x[0-9a-f]+), %Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - rz_calls = re.findall( - r"call void @__quantum__qis__rz__body\(double (0x[0-9a-f]+), %Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - s_calls = re.findall( - r"call void @__quantum__qis__s__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - t_calls = re.findall( - r"call void @__quantum__qis__t__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - tdg_calls = re.findall( - r"call void @__quantum__qis__t__adj\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # print(f"Rx calls: {rx_calls}") - # print(f"Ry calls: {ry_calls}") - # print(f"Rz calls: {rz_calls}") - # print(f"S calls: {s_calls}") - # print(f"T calls: {t_calls}") - # print(f"Tdg calls: {tdg_calls}") - - # Based on the initial gates, we can infer the qubit allocation: - if len(rx_calls) >= 1 and len(ry_calls) >= 1 and len(rz_calls) >= 1 and len(s_calls) >= 1: - # Extract the qubit indices from the first calls - original_a0 = int(rx_calls[0][1]) - original_a1 = int(ry_calls[0][1]) - original_b0 = int(rz_calls[0][1]) - original_b1 = int(s_calls[0]) - - # print("Inferred qubit allocation:") - # print(f" original a[0] -> physical qubit {original_a0}") - # print(f" original a[1] -> physical qubit {original_a1}") - # print(f" original b[0] -> physical qubit {original_b0}") - # print(f" original b[1] -> physical qubit {original_b1}") - - # Now we can verify that the gates after permutations are applied to the correct qubits - if len(rx_calls) >= 2 and len(ry_calls) >= 2: - assert int(rx_calls[1][1]) == original_b0, ( - f"Second Rx gate should be applied to original b[0] " - f"(physical qubit {original_b0}), but was applied to physical qubit {rx_calls[1][1]}" - ) - assert int(ry_calls[1][1]) == original_a0, ( - f"Second Ry gate should be applied to original a[0] " - f"(physical qubit {original_a0}), but was applied to physical qubit {ry_calls[1][1]}" - ) - - if len(t_calls) >= 1 and len(tdg_calls) >= 1: - assert int(t_calls[0]) == original_a1, ( - f"T gate should be applied to original a[1] " - f"(physical qubit {original_a1}), but was applied to physical qubit {t_calls[0]}" - ) - assert int(tdg_calls[0]) == original_b1, ( - f"Tdg gate should be applied to original b[1] " - f"(physical qubit {original_b1}), but was applied to physical qubit {tdg_calls[0]}" - ) diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_register_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_register_permutation.py deleted file mode 100644 index 177486356..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_register_permutation.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for whole register permutation functionality in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import CReg, Main, Permute, QReg, SlrConverter -from pecos.slr.qeclib import qubit - -# Test fixtures - - -def create_whole_register_permutation_program() -> tuple: - """Create a program with permutation of whole registers.""" - a = CReg("a", 5) - b = CReg("b", 5) - - return Main( - a, - b, - Permute( - a, - b, - ), - b[2].set(1), # After permutation, this still refers to b[2] - a[3].set(0), # After permutation, this still refers to a[3] - ) - - -def create_mixed_permutation_program() -> tuple: - """Create a program with both whole register and element permutations.""" - a = QReg("a", 3) - b = QReg("b", 3) - c = QReg("c", 3) - - return Main( - a, - b, - c, - # First permute specific elements - Permute( - [a[0], c[1]], - [c[1], a[0]], - ), - # Then permute whole registers a and b - Permute( - a, - b, - ), - # Apply gates to see the effect of permutations - qubit.H(a[0]), # Should apply to c[1] after both permutations - qubit.X(b[1]), # Should apply to a[1] after the whole register permutation - qubit.Z(c[2]), # Should apply to c[2] since it's not permuted - ) - - -# QASM Tests - - -def test_whole_register_permutation_qasm() -> None: - """Test permutation of whole registers in QASM generation.""" - prog = create_whole_register_permutation_program() - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - print("\nQASM output:") - print(qasm) - - # Verify the permutation comment is correct - assert ( - "// Permutation: a <-> b" in qasm or "// Permuting: a <-> b" in qasm - ), f"Expected permutation comment not found in QASM:\n{qasm}" - - # Verify the XOR swap operations are generated - assert "a = a ^ b;" in qasm, f"Expected 'a = a ^ b;' not found in QASM:\n{qasm}" - assert "b = b ^ a;" in qasm, f"Expected 'b = b ^ a;' not found in QASM:\n{qasm}" - assert "a = a ^ b;" in qasm, f"Expected 'a = a ^ b;' not found in QASM:\n{qasm}" - - # Verify the temporary bit approach is NOT used for whole register permutations - assert "creg _bit_swap[1];" not in qasm, f"Unexpected 'creg _bit_swap[1];' found in QASM:\n{qasm}" - - # For classical registers, we're using XOR swap, which swaps the values, not the references. - # For bit-level operations, the permutation is applied, so b[2].set(1) becomes b[2] = 1; - # For register-level operations, the original register name is used, so a[3].set(0) becomes a[3] = 0; - assert "b[2] = 1;" in qasm, f"Expected 'b[2] = 1;' not found in QASM:\n{qasm}" - assert "a[3] = 0;" in qasm, f"Expected 'a[3] = 0;' not found in QASM:\n{qasm}" - - -def test_mixed_permutation_qasm() -> None: - """Test mixed whole register and element permutations in QASM generation.""" - prog = create_mixed_permutation_program() - qasm = SlrConverter(prog).qasm() - - # Verify the permutation comments are correct - assert ( - "// Permutation: a <-> b" in qasm or "// Permuting: a <-> b" in qasm - ), f"Expected permutation comment not found in QASM:\n{qasm}" - assert ( - "// Permutation: a[0] -> c[1], c[1] -> a[0]" in qasm - ), f"Expected permutation comment not found in QASM:\n{qasm}" - - # For QRegs, we're using the permutation map approach, not XOR swap - # So we shouldn't see XOR operations for QRegs - assert "a = a ^ b;" not in qasm, f"Unexpected XOR operation found in QASM:\n{qasm}" - - # Verify the operations after the permutation - # For quantum registers, we're using the permutation map approach - # So H(a[0]) should become H(c[1]) after both permutations - # X(b[1]) should become X(a[1]) after the whole register permutation - # Z(c[2]) remains Z(c[2]) since it's not permuted - assert "h c[1]" in qasm, f"Expected 'h c[1]' not found in QASM:\n{qasm}" - assert "x a[1]" in qasm, f"Expected 'x a[1]' not found in QASM:\n{qasm}" - assert "z c[2]" in qasm, f"Expected 'z c[2]' not found in QASM:\n{qasm}" - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_whole_register_permutation_qir() -> None: - """Test permutation of whole registers in QIR generation.""" - prog = create_whole_register_permutation_program() - qir = SlrConverter(prog).qir() - - # Verify the permutation comment is present - assert "; Permutation: a <-> b" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify the XOR operations are present - assert "xor" in qir, f"Expected XOR operations not found in QIR:\n{qir}" - - # Verify the temporary bit approach is NOT used for whole register permutations - assert ( - "_bit_swap = call i1* @create_creg(i64 1)" not in qir - ), f"Unexpected '_bit_swap = call i1* @create_creg(i64 1)' found in QIR:\n{qir}" - assert "call i1 @get_creg_bit" not in qir, f"Unexpected 'call i1 @get_creg_bit' found in QIR:\n{qir}" - - # Verify the set operations are present - set_pattern = r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 (\d+)\)" - set_calls = re.findall(set_pattern, qir) - - # Verify the operations after the permutation - # Since we're swapping values, not references, the operations should still refer to the original registers - b2_set = False - a3_set = False - for reg, idx, val in set_calls: - if reg == "b" and idx == "2" and val == "1": - b2_set = True - if reg == "a" and idx == "3" and val == "0": - a3_set = True - - assert b2_set, f"Expected set_creg_bit(b, 2, 1) not found in QIR:\n{qir}" - assert a3_set, f"Expected set_creg_bit(a, 3, 0) not found in QIR:\n{qir}" - - -@pytest.mark.optional_dependency -def test_mixed_permutation_qir() -> None: - """Test mixed whole register and element permutations in QIR generation.""" - prog = create_mixed_permutation_program() - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - print("\nQIR output:") - print(qir) - - # Verify the permutation comments are present - assert "; Permutation: a[0] -> c[1], c[1] -> a[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - assert "; Permutation: a <-> b" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct quantum operations after permutations - # H gate should be applied to c[1] (after both permutations) - # The exact qubit index depends on how QIR allocates qubits, but we can check for the pattern - h_gate_pattern = r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - h_gates = re.findall(h_gate_pattern, qir) - assert len(h_gates) >= 1, f"Expected at least one H gate, found {len(h_gates)}" - - # X gate should be applied to a[1] (after the whole register permutation) - x_gate_pattern = r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - x_gates = re.findall(x_gate_pattern, qir) - assert len(x_gates) >= 1, f"Expected at least one X gate, found {len(x_gates)}" - - # Z gate should be applied to c[2] (which is not permuted) - z_gate_pattern = r"call void @__quantum__qis__z__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - z_gates = re.findall(z_gate_pattern, qir) - assert len(z_gates) >= 1, f"Expected at least one Z gate, found {len(z_gates)}" - - # Verify that the qubit indices for the gates are different - # This ensures that the permutations are being applied correctly - all_gates = h_gates + x_gates + z_gates - assert len(set(all_gates)) == len( - all_gates, - ), f"Expected all gates to be applied to different qubits, found duplicates: {all_gates}" diff --git a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py index 49ae0685a..16918a7d1 100644 --- a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py +++ b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py @@ -44,10 +44,8 @@ bitcode via the bundled `selene_helios_qis_plugin` (+ Helios QIR runtime) -- `selene_sim.build(BitcodeString(...)) -> run_shots(Stim)`. So the direct `qir_to_qis -> Selene` - differential long claimed "blocked" (an - alleged LLVM 14<->21 / opaque-vs-typed bridge) is in fact - available with zero PECOS LLVM work: PECOS-Rust stays LLVM-14; - the LLVM-21 capability lives entirely in the qir-qis + + differential long claimed "blocked" by LLVM version or pointer + representation differences is available through the qir-qis + selene_sim *Python* deps. Layer D (`test_tier2_executable_differential`) lands the **representative** executable differential (deterministic @@ -85,17 +83,25 @@ _SHOTS = 8 _SEED = 42 _BESPOKE = re.compile(r"create_creg|get_creg_bit|set_creg_bit|get_int_from_creg|set_creg_to_int|mz_to_creg_bit") -# Ordered mz/read_result events (slot in g1 for mz, g2 for read_result) so +# Ordered mz/read_result events (slot arg in g1 for mz, g2 for read_result) so # we can pin per-measurement slot correspondence, not just set membership. +# QIR uses LLVM opaque pointers: a result-slot constant is +# `ptr inttoptr (i64 N to ptr)`, except slot 0 which is `ptr null`. _MZ_RR_EVENT = re.compile( - r"@__quantum__qis__mz__body\(%Qubit\* [^,]+, " - r"%Result\* inttoptr \(i64 (\d+) to %Result\*\)\)" - r"|@__quantum__rt__read_result\(%Result\* inttoptr \(i64 (\d+) to %Result\*\)\)", + r"@__quantum__qis__mz__body\(ptr [^,]+, " + r"ptr (null|inttoptr \(i64 \d+ to ptr\))\)" + r"|@__quantum__rt__read_result\(ptr (null|inttoptr \(i64 \d+ to ptr\))\)", ) +def _slot(arg: str) -> int: + """Decode a `ptr` result-slot constant (`null` is slot 0).""" + m = re.search(r"i64 (\d+)", arg) + return int(m.group(1)) if m else 0 + + def _assert_mz_rr_pairing(label: str, ir: str) -> None: - """Per-measurement slot correspondence: mz `%Result*` slots are + """Per-measurement slot correspondence: mz result slots are monotonic 0..n-1, and EVERY `read_result` is immediately preceded (in emission order) by the `mz__body` of the SAME slot -- i.e. a read_result reuses *its own* measurement's static slot, not merely @@ -103,11 +109,26 @@ def _assert_mz_rr_pairing(label: str, ir: str) -> None: events: list[tuple[str, int]] = [] for m in _MZ_RR_EVENT.finditer(ir): if m.group(1) is not None: - events.append(("mz", int(m.group(1)))) + events.append(("mz", _slot(m.group(1)))) else: - events.append(("rr", int(m.group(2)))) + events.append(("rr", _slot(m.group(2)))) + # Tripwire: if the emitted pointer syntax ever drifts away from EITHER + # half of the event regex, fail loud instead of vacuously passing. + # Count raw call sites (the `declare` preamble lines do not contain + # "call") and require the regex to have matched every one of them. + mz_calls = ir.count("call void @__quantum__qis__mz__body") + rr_calls = ir.count("call i1 @__quantum__rt__read_result") + mz_events = sum(1 for kind, _ in events if kind == "mz") + rr_events = sum(1 for kind, _ in events if kind == "rr") + assert ( + mz_events == mz_calls + ), f"{label}: _MZ_RR_EVENT matched {mz_events} of {mz_calls} mz calls; pattern out of sync with emitted IR" + assert rr_events == rr_calls, ( + f"{label}: _MZ_RR_EVENT matched {rr_events} of {rr_calls} read_result calls; " + f"pattern out of sync with emitted IR" + ) mz_slots = [s for kind, s in events if kind == "mz"] - assert mz_slots == list(range(len(mz_slots))), f"{label}: mz %Result* slots not monotonic 0..n-1: {mz_slots}" + assert mz_slots == list(range(len(mz_slots))), f"{label}: mz result slots not monotonic 0..n-1: {mz_slots}" for i, (kind, slot) in enumerate(events): if kind != "rr": continue @@ -126,6 +147,15 @@ def _case(label: str) -> Main: raise KeyError(msg) +def _creg_storage_operand(size: int, name: str) -> str: + """Match LLVM 21 opaque `ptr %c` and legacy typed `[N x i1]* %c` IR.""" + return rf"(?:ptr|\[{size} x i1\]\*) %{re.escape(name)}" + + +def _creg_gep_pattern(size: int, name: str, index: int) -> str: + return rf"getelementptr \[{size} x i1\], {_creg_storage_operand(size, name)}, i64 0, i64 {index}" + + # --- extra programs not in the corpus (plan amendment 8 coverage) --- @@ -190,8 +220,9 @@ def _layer_b_structural(prog: Main, label: str, *, creg_sizes: dict[str, int]) - for name, size in creg_sizes.items(): assert f"%{name} = alloca [{size} x i1]" in ir, f"{label}: missing entry-block alloca for CReg {name!r}" - assert ( - f"store [{size} x i1] zeroinitializer, [{size} x i1]* %{name}" in ir + assert re.search( + rf"store \[{size} x i1\] zeroinitializer, {_creg_storage_operand(size, name)}", + ir, ), f"{label}: missing zeroinitializer for CReg {name!r} (unset bits must read 0, not undef)" _assert_mz_rr_pairing(label, ir) @@ -212,10 +243,7 @@ def _assert_set_int_unpack(label: str, ir: str, *, name: str, size: int, value: """ for i in range(size): bit = (value >> i) & 1 - pat = ( - rf"getelementptr \[{size} x i1\], \[{size} x i1\]\* %{name}, " - rf"i64 0, i64 {i}\n\s*store i1 {bit}, i1\* %[.\w]+" - ) + pat = rf"{_creg_gep_pattern(size, name, i)}\n\s*store i1 {bit}, (?:ptr|i1\*) %[.\w]+" assert re.search( pat, ir, @@ -232,11 +260,12 @@ def _assert_zero_init_predicate(label: str, ir: str, *, name: str) -> None: """`If(c[i])` before any write must branch on a `load` of the zero-initialised buffer (so the predicate is deterministically 0, never `undef`).""" - assert ( - f"store [1 x i1] zeroinitializer, [1 x i1]* %{name}" in ir + assert re.search( + rf"store \[1 x i1\] zeroinitializer, {_creg_storage_operand(1, name)}", + ir, ), f"{label}: missing zeroinitializer for the pre-read CReg {name!r}" assert re.search( - rf"getelementptr \[1 x i1\], \[1 x i1\]\* %{name}, i64 0, i64 0\n\s*%[.\w]+ = load i1", + rf"{_creg_gep_pattern(1, name, 0)}\n\s*%[.\w]+ = load i1", ir, ), f"{label}: If(c[0]) predicate is not a load of the zero-inited buffer" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_basic_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_basic_permutation.py index 0c046dfb9..519875acd 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_basic_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_basic_permutation.py @@ -166,7 +166,10 @@ def test_basic_permutation_qir(basic_permutation_program: tuple) -> None: assert "; Permutation: a[0] -> b[1], b[1] -> a[0]" in qir, qir # a[0].set(1) after the swap writes b[1]'s slot, not a[0]'s. - m = re.search(r"%(\.\d+) = getelementptr \[2 x i1\], \[2 x i1\]\* %b, i64 0, i64 1\n\s*store i1 1, i1\* %\1", qir) + m = re.search( + r"%(\.\d+) = getelementptr \[2 x i1\], (?:ptr|\[2 x i1\]\*) %b, i64 0, i64 1\n\s*store i1 1, (?:ptr|i1\*) %\1", + qir, + ) assert m, f"Expected a[0].set(1) to store into b[1] (relabelled):\n{qir}" assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -185,8 +188,8 @@ def test_same_register_permutation_qir( # a[0].set(1)->a[2], a[1].set(0)->a[0], a[2].set(1)->a[1]. def _stored(slot: int, val: int) -> bool: pat = ( - rf"%(\.\d+) = getelementptr \[3 x i1\], \[3 x i1\]\* %a, i64 0, i64 {slot}\n" - rf"\s*store i1 {val}, i1\* %\1" + rf"%(\.\d+) = getelementptr \[3 x i1\], (?:ptr|\[3 x i1\]\*) %a, i64 0, i64 {slot}\n" + rf"\s*store i1 {val}, (?:ptr|i1\*) %\1" ) return bool( re.search( diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py index 1744823d7..7a6ce48eb 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py @@ -126,6 +126,16 @@ def test_permutation_with_conditional_qasm() -> None: # tracker's `.permute()`; QIR/Selene have no runtime permute # intrinsic). These pin the realized targeting from the actual # emitted QIR (qubit indices deterministic in declaration order). +# +# QIR uses LLVM opaque pointers: a qubit constant is +# `ptr inttoptr (i64 N to ptr)`, except index 0 which is `ptr null`. + +_QARG = r"ptr (?:null|inttoptr \(i64 (\d+) to ptr\))" + + +def _q(name: str, qir: str) -> list[int]: + """All qubit indices a single-qubit `name` gate is applied to.""" + return [int(m) if m else 0 for m in re.findall(rf"call void @__quantum__qis__{name}__body\({_QARG}\)", qir)] @pytest.mark.optional_dependency @@ -149,10 +159,8 @@ def test_multiple_permutations_qir() -> None: assert "; Permutation: a[0] -> a[1], a[1] -> a[0]" in qir, qir assert "; Permutation: a[1] -> b[0], b[0] -> a[1]" in qir, qir # Qubits: a=0,1,2 b=3,4,5. Both H(a[0]) -> q1; X(a[1]) -> b[0]=q3. - h = re.findall(r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) - x = re.findall(r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) - assert h == ["1", "1"], qir - assert x == ["3"], qir + assert _q("h", qir) == [1, 1], qir + assert _q("x", qir) == [3], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -179,10 +187,10 @@ def test_permutation_with_conditional_qir() -> None: assert "; Permutation: a[0] -> a[1], a[1] -> a[0], b[0] -> b[1], b[1] -> b[0]" in qir, qir # b[0].set(1) is BEFORE the permute -> b[0] slot (index 0). assert re.search( - r"%(\.\d+) = getelementptr \[2 x i1\], \[2 x i1\]\* %b, i64 0, i64 0\n\s*store i1 1, i1\* %\1", + r"%(\.\d+) = getelementptr \[2 x i1\], (?:ptr|\[2 x i1\]\*) %b, i64 0, i64 0\n\s*store i1 1, (?:ptr|i1\*) %\1", qir, ), qir # X(a[0]) after the permute -> a[1] = q1. - assert re.findall(r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) == ["1"], qir + assert _q("x", qir) == [1], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_creg_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_creg_permutation.py index 53cd8fef8..0b0e46714 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_creg_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_creg_permutation.py @@ -71,8 +71,8 @@ def test_creg_permutation_qir() -> None: # a[0].set(1) after Permute(a, b) -> stores into register b's # `[1 x i1]` buffer (a[0] now resolves to b[0]); register a's # buffer is never the store target for that set. - assert re.search(r"store i1 1, i1\* %\.\d+", qir), qir - b_slot = re.search(r"%(\.\d+) = getelementptr \[1 x i1\], \[1 x i1\]\* %b, i64 0, i64 0", qir) + assert re.search(r"store i1 1, (?:ptr|i1\*) %\.\d+", qir), qir + b_slot = re.search(r"%(\.\d+) = getelementptr \[1 x i1\], (?:ptr|\[1 x i1\]\*) %b, i64 0, i64 0", qir) assert b_slot, f"Expected a[0].set(1) to target register b's buffer (relabelled):\n{qir}" assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py index a88609b93..6f906f3e8 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py @@ -70,20 +70,28 @@ def test_register_measurement_permutation_qasm( # every qubit/classical-bit lowering (the bespoke # @create_creg/@set_creg_bit/@mz_to_creg_bit helpers the old tests # pinned were removed by the static CReg model; measurement is the standard 2-arg -# `@__quantum__qis__mz__body(%Qubit*, %Result*)` + read_result + +# `@__quantum__qis__mz__body(ptr, ptr)` + read_result + # store-into-creg-buffer). Pin the realized measurement targeting. +# +# QIR uses LLVM opaque pointers: a qubit/result constant is +# `ptr inttoptr (i64 N to ptr)`, except index 0 which is `ptr null`. -def _mz_then_store(qir: str) -> list[tuple[str, str, str]]: +def _mz_then_store(qir: str) -> list[tuple[int, str, int]]: """(qubit_idx, creg_name, creg_idx) for each measure+store.""" pat = ( - r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Result\* inttoptr \(i64 (\d+) to %Result\*\)\)\n" - r"\s*%(?:\.\d+) = call i1 @__quantum__rt__read_result\(%Result\* inttoptr \(i64 \2 to %Result\*\)\)\n" - r"\s*%(\.\d+) = getelementptr \[\d+ x i1\], \[\d+ x i1\]\* %(\w+), i64 0, i64 (\d+)" + r"call void @__quantum__qis__mz__body\(ptr (null|inttoptr \(i64 \d+ to ptr\)), " + r"(ptr (?:null|inttoptr \(i64 \d+ to ptr\)))\)\n" + r"\s*%(?:\.\d+) = call i1 @__quantum__rt__read_result\(\2\)\n" + r"\s*%(\.\d+) = getelementptr \[\d+ x i1\], (?:ptr|\[\d+ x i1\]\*) %(\w+), i64 0, i64 (\d+)" ) - # groups: 1=qubit, 2=result idx, 3=gep var, 4=creg name, 5=creg idx - return [(m[0], m[3], m[4]) for m in re.findall(pat, qir)] + # groups: 1=qubit arg, 2=result arg (backreferenced into read_result), + # 3=gep var, 4=creg name, 5=creg idx + out = [] + for qubit_arg, _result_arg, _gep, creg_name, creg_idx in re.findall(pat, qir): + idx = re.search(r"i64 (\d+)", qubit_arg) + out.append((int(idx.group(1)) if idx else 0, creg_name, int(creg_idx))) + return out @pytest.mark.optional_dependency @@ -99,7 +107,7 @@ def test_individual_measurement_permutation_qir( # Qubits a=0,1 b=2,3. Measure(a[0]) -> b[0]=q2, result -> m[0] # which is relabelled to n[0]. Measure(a[1]) -> q1 (unpermuted), # result -> m[1] (unpermuted). - assert _mz_then_store(qir) == [("2", "n", "0"), ("1", "m", "1")], qir + assert _mz_then_store(qir) == [(2, "n", 0), (1, "m", 1)], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -116,6 +124,6 @@ def test_register_measurement_permutation_qir( assert "; Permutation: m[0] -> n[0], n[0] -> m[0]" in qir, qir # Measure(a) unrolls: a[0]->b[0]=q2 (-> m[0] relabelled to n[0]), # a[1]->q1 (-> m[1] unpermuted). - assert _mz_then_store(qir) == [("2", "n", "0"), ("1", "m", "1")], qir + assert _mz_then_store(qir) == [(2, "n", 0), (1, "m", 1)], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_unrolling.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_unrolling.py index b306ab817..298de00ba 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_unrolling.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_unrolling.py @@ -92,9 +92,17 @@ def test_measurement_unrolling_qir() -> None: assert "; Permutation: a[0] -> c[2], b[1] -> a[0], c[2] -> b[1]" in qir, qir assert "; Permutation: a <-> c" in qir, qir - mz = re.findall(r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), %Result\*", qir) + # QIR uses opaque pointers; a qubit constant is + # `ptr inttoptr (i64 N to ptr)`, except index 0 which is `ptr null`. + mz = [ + int(m) if m else 0 + for m in re.findall( + r"call void @__quantum__qis__mz__body\(ptr (?:null|inttoptr \(i64 (\d+) to ptr\)), ptr", + qir, + ) + ] # Measure(a) after both permutes -> a relabelled onto c's qubits # plus the element cycle: realized as q6, q7, q4. - assert mz == ["6", "7", "4"], qir + assert mz == [6, 7, 4], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_quantum_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_quantum_permutation.py index fc660ea91..04002695c 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_quantum_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_quantum_permutation.py @@ -65,14 +65,22 @@ def test_quantum_permutation_qasm(quantum_permutation_program: tuple) -> None: # `; Permutation:` comment is preserved. The bespoke # @set_creg_bit/@mz_to_creg_bit helpers the old tests pinned were # removed by the static CReg model (measurement is the standard 2-arg -# `@__quantum__qis__mz__body(%Qubit*, %Result*)`). +# `@__quantum__qis__mz__body(ptr, ptr)`). +# +# QIR uses LLVM opaque pointers: a qubit/result constant is +# `ptr inttoptr (i64 N to ptr)`, except index 0 which is `ptr null`. + +_QARG = r"ptr (?:null|inttoptr \(i64 (\d+) to ptr\))" + + +def _idx(m: str) -> int: + """Decode a captured pointer index; the empty capture is `ptr null` = 0.""" + return int(m) if m else 0 def _q(name: str, qir: str) -> list[int]: """All qubit indices a single-qubit `name` gate is applied to.""" - return [ - int(m) for m in re.findall(rf"call void @__quantum__qis__{name}__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) - ] + return [_idx(m) for m in re.findall(rf"call void @__quantum__qis__{name}__body\({_QARG}\)", qir)] @pytest.mark.optional_dependency @@ -86,12 +94,10 @@ def test_quantum_permutation_qir(quantum_permutation_program: tuple) -> None: # Qubits: a[0]=0, a[1]=1, b[0]=2, b[1]=3. After the swap, H(a[0]) # targets b[0]'s qubit (2) and CX(a[0], a[1]) -> cnot(2, 1). assert _q("h", qir) == [2], qir - cnot = re.findall( - r"call void @__quantum__qis__cnot__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - assert cnot == [("2", "1")], qir + cnot = [ + (_idx(c), _idx(t)) for c, t in re.findall(rf"call void @__quantum__qis__cnot__body\({_QARG}, {_QARG}\)", qir) + ] + assert cnot == [(2, 1)], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -126,11 +132,8 @@ def test_permutation_with_bell_circuit_qir() -> None: assert _q("h", qir) == [3], qir # Standard 2-arg measurement (the removed @mz_to_creg_bit is # gone): Measure(a[0]) reads q3, Measure(a[1]) reads q1. - mz = re.findall( - r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), %Result\*", - qir, - ) - assert mz == ["3", "1"], qir + mz = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__mz__body\({_QARG}, ptr", qir)] + assert mz == [3, 1], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -178,17 +181,12 @@ def test_comprehensive_qir_verification() -> None: assert _q("x", qir) == [1, 0], qir # initial a[1]=1, then ->a[0]=0 assert _q("y", qir) == [2, 3], qir # initial b[0]=2, then ->b[1]=3 assert _q("z", qir) == [3, 1], qir # initial b[1]=3, then ->a[1]=1 - cnot = re.findall( - r"call void @__quantum__qis__cnot__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - assert cnot == [("2", "1")], qir - mz = re.findall( - r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), %Result\*", - qir, - ) - assert mz == ["2", "1"], qir + cnot = [ + (_idx(c), _idx(t)) for c, t in re.findall(rf"call void @__quantum__qis__cnot__body\({_QARG}, {_QARG}\)", qir) + ] + assert cnot == [(2, 1)], qir + mz = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__mz__body\({_QARG}, ptr", qir)] + assert mz == [2, 1], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -216,13 +214,13 @@ def test_rotation_gates_with_permutation() -> None: qir = SlrConverter(prog).qir() assert "; Permutation: a[0] -> b[0], b[0] -> a[0]" in qir - rx = re.findall(r"call void @__quantum__qis__rx__body\(double [^,]+, %Qubit\* inttoptr \(i64 (\d+) ", qir) - ry = re.findall(r"call void @__quantum__qis__ry__body\(double [^,]+, %Qubit\* inttoptr \(i64 (\d+) ", qir) - assert rx == ["0", "2"], qir # initial a[0]=0, then ->b[0]=2 - assert ry == ["1", "0"], qir # initial a[1]=1, then ->a[0]=0 + rx = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__rx__body\(double [^,]+, {_QARG}\)", qir)] + ry = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__ry__body\(double [^,]+, {_QARG}\)", qir)] + assert rx == [0, 2], qir # initial a[0]=0, then ->b[0]=2 + assert ry == [1, 0], qir # initial a[1]=1, then ->a[0]=0 assert _q("t", qir) == [1], qir # T(a[1]) unpermuted - tdg = re.findall(r"call void @__quantum__qis__t__adj\(%Qubit\* inttoptr \(i64 (\d+) ", qir) - assert tdg == ["3"], qir # Tdg(b[1]) unpermuted + tdg = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__t__adj\({_QARG}\)", qir)] + assert tdg == [3], qir # Tdg(b[1]) unpermuted assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py index 8e49e8eb0..c96311c27 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py @@ -123,6 +123,13 @@ def test_mixed_permutation_qasm() -> None: # relabelled register's `[N x i1]` buffer). Works for whole-register # (QReg + CReg) and element-wise; pinned from the actual emitted QIR. +_QARG = r"ptr (?:null|inttoptr \(i64 (\d+) to ptr\))" + + +def _q(name: str, qir: str) -> list[int]: + """All qubit indices a single-qubit `name` gate is applied to.""" + return [int(m) if m else 0 for m in re.findall(rf"call void @__quantum__qis__{name}__body\({_QARG}\)", qir)] + @pytest.mark.optional_dependency def test_whole_register_permutation_qir() -> None: @@ -135,11 +142,11 @@ def test_whole_register_permutation_qir() -> None: # Permute(a, b); b[2].set(1); a[3].set(0). After the swap, b[2] # resolves to a[2] and a[3] resolves to b[3]. assert re.search( - r"%(\.\d+) = getelementptr \[5 x i1\], \[5 x i1\]\* %a, i64 0, i64 2\n\s*store i1 1, i1\* %\1", + r"%(\.\d+) = getelementptr \[5 x i1\], (?:ptr|\[5 x i1\]\*) %a, i64 0, i64 2\n\s*store i1 1, (?:ptr|i1\*) %\1", qir, ), f"b[2].set(1) should target a[2] after swap:\n{qir}" assert re.search( - r"%(\.\d+) = getelementptr \[5 x i1\], \[5 x i1\]\* %b, i64 0, i64 3\n\s*store i1 0, i1\* %\1", + r"%(\.\d+) = getelementptr \[5 x i1\], (?:ptr|\[5 x i1\]\*) %b, i64 0, i64 3\n\s*store i1 0, (?:ptr|i1\*) %\1", qir, ), f"a[3].set(0) should target b[3] after swap:\n{qir}" @@ -156,9 +163,9 @@ def test_mixed_permutation_qir() -> None: assert "; Permutation: a <-> b" in qir, qir # QRegs a=0-2, b=3-5, c=6-8. First Permute([a[0],c[1]],[c[1],a[0]]) # then whole Permute(a, b). Realized: H(a[0])->q3, X(b[1])->q1, - # Z(c[2])->q8. - assert re.findall(r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) == ["3"], qir - assert re.findall(r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) == ["1"], qir - assert re.findall(r"call void @__quantum__qis__z__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) == ["8"], qir + # Z(c[2])->q8. (QIR uses opaque pointers; index 0 is `ptr null`.) + assert _q("h", qir) == [3], qir + assert _q("x", qir) == [1], qir + assert _q("z", qir) == [8], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/selene-plugins/pecos-selene-mast/Cargo.toml b/python/selene-plugins/pecos-selene-mast/Cargo.toml index 2f14e16cb..32f92050e 100644 --- a/python/selene-plugins/pecos-selene-mast/Cargo.toml +++ b/python/selene-plugins/pecos-selene-mast/Cargo.toml @@ -18,7 +18,7 @@ anyhow = { workspace = true } pecos-core = { workspace = true } pecos-simulators = { workspace = true } pecos-stab-tn = { path = "../../../exp/pecos-stab-tn" } -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml b/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml index ea91374eb..ccbcd0f5b 100644 --- a/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml +++ b/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml @@ -18,7 +18,7 @@ anyhow = { workspace = true } pecos-core = { workspace = true } pecos-simulators = { workspace = true } pecos-stab-tn = { path = "../../../exp/pecos-stab-tn" } -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml b/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml index 40cb39f97..4e00dff27 100644 --- a/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml +++ b/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml @@ -19,7 +19,7 @@ pecos-core = { workspace = true } pecos-simulators = { workspace = true } # selene-core is a git dependency since it's not published to crates.io # Use the same revision as the other selene plugins for consistency -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/python/selene-plugins/pecos-selene-stabilizer/Cargo.toml b/python/selene-plugins/pecos-selene-stabilizer/Cargo.toml index 9892f6b11..2714ab55f 100644 --- a/python/selene-plugins/pecos-selene-stabilizer/Cargo.toml +++ b/python/selene-plugins/pecos-selene-stabilizer/Cargo.toml @@ -20,7 +20,7 @@ pecos-simulators = { workspace = true } pecos-core = { workspace = true } # selene-core is a git dependency since it's not published to crates.io # Use the same revision as pecos-qis for consistency -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/python/selene-plugins/pecos-selene-statevec/Cargo.toml b/python/selene-plugins/pecos-selene-statevec/Cargo.toml index 3c30bd733..fd804cea6 100644 --- a/python/selene-plugins/pecos-selene-statevec/Cargo.toml +++ b/python/selene-plugins/pecos-selene-statevec/Cargo.toml @@ -19,7 +19,7 @@ pecos-core = { workspace = true } pecos-simulators = { workspace = true } # selene-core is a git dependency since it's not published to crates.io # Use the same revision as pecos-qis for consistency -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/ruff.toml b/ruff.toml index 721f369aa..711cbdb60 100644 --- a/ruff.toml +++ b/ruff.toml @@ -197,6 +197,14 @@ ignore = [ "SLF001", # Private member access - accessing internal circuit/tick data "PLW0127", # Self-assignment - intentional pattern for influence propagation ] +# QEC tests that unit-test internal decoder methods directly (kept private; not +# promoted to the public API just to be testable) +"python/quantum-pecos/tests/qec/surface/test_surface_decoder.py" = [ + "SLF001", # Private member access - unit-testing internal DEM detection-event methods +] +"python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py" = [ + "SLF001", # Private member access - unit-testing the internal graphlike-decomposition recommendation +] # SLR qeclib visualization - matplotlib lazy imports "python/quantum-pecos/src/pecos/slr/qeclib/color488/plot_layout.py" = [ "PLC0415", # Import inside function - matplotlib optional @@ -270,6 +278,18 @@ ignore = [ "S108", # Examples commonly default to /tmp output paths "S311", # Example sweeps use deterministic pseudo-random circuits, not crypto ] +# Experimental SLR/zlup crate scripts are scripts, not packages -- same relaxations +# as the top-level scripts/ (other rules, e.g. pathlib usage, are still enforced). +"exp/**/scripts/**/*.py" = [ + "ANN", # Command-line scripts do not need library-level annotations + "D", # Top-level module docstrings document scripts + "EXE001", # Scripts are commonly invoked through `python path/to/script.py` + "INP001", # Script files are not import packages + "S603", # Scripts may call trusted local tools (cargo, mdbook) + "PLC0415", # Lazy imports keep optional dependencies out of import time + "S301", # Scripts may read trusted local data artifacts + "BLE001", # CLI/report scripts often convert broad failures into user-facing output +] # Jupyter notebooks - exploratory code, less strict than library code diff --git a/scripts/check_python_workspace.py b/scripts/check_python_workspace.py index 5d0259b09..77fb00d85 100644 --- a/scripts/check_python_workspace.py +++ b/scripts/check_python_workspace.py @@ -159,16 +159,35 @@ def check_cuda_extra_group(root_data: dict[str, Any], errors: list[str]) -> None project = root_data.get("project", {}) optional = project.get("optional-dependencies", {}) if isinstance(project, dict) else {} dependency_groups = root_data.get("dependency-groups", {}) - cuda_extra = optional.get("cuda") if isinstance(optional, dict) else None - cuda_group = dependency_groups.get("cuda") if isinstance(dependency_groups, dict) else None - - if cuda_extra is None or cuda_group is None: + if not isinstance(optional, dict) or not isinstance(dependency_groups, dict): return - if cuda_extra != cuda_group: - fail( - errors, - "pyproject.toml: [project.optional-dependencies].cuda and [dependency-groups].cuda must stay identical", - ) + + # The CUDA stack is split by toolkit major; each major must be defined as BOTH a + # `[project.optional-dependencies]` extra AND a matching `[dependency-groups]` + # group, kept identical, so `pip install .[cuda13]` and `uv sync --group cuda13` + # resolve to the same packages. Both majors are required: the `pecos` CLI selects + # cuda12 or cuda13 by the detected toolkit (cuda_python_group), so deleting either + # the extra or the group breaks CUDA setup on the corresponding host -- a missing + # side is an error, not a silent skip. + for cuda_name in ("cuda12", "cuda13"): + cuda_extra = optional.get(cuda_name) + cuda_group = dependency_groups.get(cuda_name) + if cuda_extra is None: + fail( + errors, + f"pyproject.toml: missing required [project.optional-dependencies].{cuda_name}", + ) + if cuda_group is None: + fail( + errors, + f"pyproject.toml: missing required [dependency-groups].{cuda_name}", + ) + if cuda_extra is not None and cuda_group is not None and cuda_extra != cuda_group: + fail( + errors, + f"pyproject.toml: [project.optional-dependencies].{cuda_name} and " + f"[dependency-groups].{cuda_name} must stay identical", + ) def main() -> int: diff --git a/scripts/ci/install-llvm-21-conda-linux.sh b/scripts/ci/install-llvm-21-conda-linux.sh new file mode 100755 index 000000000..7bb3b845b --- /dev/null +++ b/scripts/ci/install-llvm-21-conda-linux.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +LLVM_VERSION="${LLVM_VERSION:-21.1}" +LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" +INSTALL_DIR="${LLVM_INSTALL_DIR:-$HOME/.pecos/deps/llvm-$LLVM_VERSION}" +MAMBA_VERSION="${MAMBA_VERSION:-latest}" +MAMBA_ROOT_PREFIX="${MAMBA_ROOT_PREFIX:-$HOME/.cache/pecos-micromamba}" + +case "$(uname -m)" in + x86_64|amd64) + MAMBA_PLATFORM="linux-64" + ;; + aarch64|arm64) + MAMBA_PLATFORM="linux-aarch64" + ;; + *) + echo "Unsupported Linux architecture for conda-forge LLVM ${LLVM_RELEASE_VERSION}: $(uname -m)" >&2 + exit 1 + ;; +esac + +llvm_is_valid() { + local llvm_config="$1" + local llvm_dir + + llvm_dir="$(dirname "$(dirname "$llvm_config")")" + + [ -x "$llvm_config" ] || return 1 + "$llvm_config" --version | grep -q '^21\.1' || return 1 + [ "$("$llvm_config" --shared-mode)" = "shared" ] || return 1 + "$llvm_config" --libnames --link-shared | grep -q 'libLLVM-21\.so' + find "$llvm_dir/lib" -maxdepth 1 \ + \( -name 'libclang.so' -o -name 'libclang-*.so' -o -name 'libclang.so.*' -o -name 'libclang-*.so.*' \) \ + | grep -q . +} + +LLVM_CONFIG="$INSTALL_DIR/bin/llvm-config" +if llvm_is_valid "$LLVM_CONFIG"; then + echo "Shared LLVM $("$LLVM_CONFIG" --version) already installed at $INSTALL_DIR" + exit 0 +fi + +if [ -e "$INSTALL_DIR" ]; then + echo "Removing invalid or non-shared LLVM install at $INSTALL_DIR" + rm -rf "$INSTALL_DIR" +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +if command -v micromamba >/dev/null 2>&1; then + MAMBA_BIN="$(command -v micromamba)" +else + MAMBA_URL="https://micro.mamba.pm/api/micromamba/${MAMBA_PLATFORM}/${MAMBA_VERSION}" + MAMBA_ARCHIVE="$TMP_DIR/micromamba.tar.bz2" + + echo "Downloading micromamba for ${MAMBA_PLATFORM}" + curl --fail --location --retry 5 --retry-delay 5 --output "$MAMBA_ARCHIVE" "$MAMBA_URL" + tar -xjf "$MAMBA_ARCHIVE" -C "$TMP_DIR" bin/micromamba + MAMBA_BIN="$TMP_DIR/bin/micromamba" +fi + +echo "Installing conda-forge LLVM ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" +MAMBA_ROOT_PREFIX="$MAMBA_ROOT_PREFIX" "$MAMBA_BIN" create \ + -y \ + -p "$INSTALL_DIR" \ + --override-channels \ + -c conda-forge \ + "llvmdev=${LLVM_RELEASE_VERSION}" \ + "libclang=${LLVM_RELEASE_VERSION}" + +if ! llvm_is_valid "$LLVM_CONFIG"; then + echo "conda-forge LLVM install did not provide shared LLVM and libclang ${LLVM_RELEASE_VERSION}" >&2 + "$LLVM_CONFIG" --version >&2 || true + "$LLVM_CONFIG" --shared-mode >&2 || true + "$LLVM_CONFIG" --libnames --link-shared >&2 || true + find "$INSTALL_DIR/lib" -maxdepth 1 -name 'libclang*' -print >&2 || true + exit 1 +fi + +"$LLVM_CONFIG" --version +"$LLVM_CONFIG" --shared-mode +"$LLVM_CONFIG" --libnames --link-shared +find "$INSTALL_DIR/lib" -maxdepth 1 \ + \( -name 'libclang.so' -o -name 'libclang-*.so' -o -name 'libclang.so.*' -o -name 'libclang-*.so.*' \) \ + -print +echo "Installed shared LLVM and libclang ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" diff --git a/scripts/ci/install-llvm-21-release.sh b/scripts/ci/install-llvm-21-release.sh new file mode 100755 index 000000000..14a553700 --- /dev/null +++ b/scripts/ci/install-llvm-21-release.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +LLVM_VERSION="${LLVM_VERSION:-21.1}" +LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" +INSTALL_DIR="${LLVM_INSTALL_DIR:-$HOME/.pecos/deps/llvm-$LLVM_VERSION}" + +case "$(uname -m)" in + x86_64|amd64) + ASSET="LLVM-${LLVM_RELEASE_VERSION}-Linux-X64.tar.xz" + SHA256="b3b7f2801d15d50736acea3c73982994d025b01c2f035b91ae3b49d1b575732b" + ;; + aarch64|arm64) + ASSET="LLVM-${LLVM_RELEASE_VERSION}-Linux-ARM64.tar.xz" + SHA256="65ce0b329514e5643407db2d02a5bd34bf33d159055dafa82825c8385bd01993" + ;; + *) + echo "Unsupported Linux architecture for LLVM ${LLVM_RELEASE_VERSION}: $(uname -m)" >&2 + exit 1 + ;; +esac + +llvm_is_shared() { + local llvm_config="$1" + + [ -x "$llvm_config" ] || return 1 + "$llvm_config" --version | grep -q '^21\.1' || return 1 + [ "$("$llvm_config" --shared-mode)" = "shared" ] || return 1 + "$llvm_config" --libnames --link-shared | grep -q 'libLLVM-21\.so' +} + +if llvm_is_shared "$INSTALL_DIR/bin/llvm-config"; then + echo "Shared LLVM $("$INSTALL_DIR/bin/llvm-config" --version) already installed at $INSTALL_DIR" + exit 0 +elif [ -e "$INSTALL_DIR" ]; then + echo "Removing invalid or non-shared LLVM install at $INSTALL_DIR" + rm -rf "$INSTALL_DIR" +fi + +URL="https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_RELEASE_VERSION}/${ASSET}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +ARCHIVE="$TMP_DIR/$ASSET" +EXTRACT_DIR="$TMP_DIR/extract" + +echo "Downloading official LLVM ${LLVM_RELEASE_VERSION} package: $ASSET" +if command -v curl >/dev/null 2>&1; then + curl --fail --location --retry 5 --retry-delay 5 --output "$ARCHIVE" "$URL" +else + python3 - "$URL" "$ARCHIVE" <<'PY' +import sys +import urllib.request + +url, dest = sys.argv[1], sys.argv[2] +with urllib.request.urlopen(url) as response, open(dest, "wb") as out: + out.write(response.read()) +PY +fi + +echo "$SHA256 $ARCHIVE" | sha256sum -c - + +mkdir -p "$EXTRACT_DIR" +tar -xJf "$ARCHIVE" -C "$EXTRACT_DIR" --strip-components=1 + +rm -rf "$INSTALL_DIR" +mkdir -p "$(dirname "$INSTALL_DIR")" +mv "$EXTRACT_DIR" "$INSTALL_DIR" + +"$INSTALL_DIR/bin/llvm-config" --version +"$INSTALL_DIR/bin/llvm-config" --shared-mode +if ! llvm_is_shared "$INSTALL_DIR/bin/llvm-config"; then + echo "The official LLVM ${LLVM_RELEASE_VERSION} Linux archive does not provide libLLVM-21.so." >&2 + echo "PECOS CI needs a shared LLVM build; use scripts/ci/install-llvm-21-conda-linux.sh instead." >&2 + exit 1 +fi +echo "Installed LLVM ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 new file mode 100644 index 000000000..c772a220b --- /dev/null +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -0,0 +1,332 @@ +param( + [string]$InstallDir = (Join-Path $env:USERPROFILE ".pecos\deps\llvm-21.1"), + [string]$Version = "" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +if (-not $Version) { + if ($env:LLVM_RELEASE_VERSION) { + $Version = $env:LLVM_RELEASE_VERSION + } + else { + $Version = "21.1.8" + } +} + +$RequiredVersion = "21.1" +$MambaVersion = if ($env:MAMBA_VERSION) { $env:MAMBA_VERSION } else { "latest" } +$MambaRoot = if ($env:MAMBA_ROOT_PREFIX) { + $env:MAMBA_ROOT_PREFIX +} +else { + Join-Path $env:USERPROFILE ".cache\pecos-micromamba" +} +$LlvmPrefix = Join-Path $InstallDir "Library" +$LlvmConfig = Join-Path $LlvmPrefix "bin\llvm-config.exe" +$LlvmBin = Join-Path $LlvmPrefix "bin" +$LlvmLib = Join-Path $LlvmPrefix "lib" +$Libclang = Join-Path $LlvmPrefix "bin\libclang.dll" + +function Find-SevenZip { + foreach ($Name in @("7z.exe", "7zz.exe", "7za.exe")) { + $Command = Get-Command $Name -ErrorAction SilentlyContinue + if ($Command) { + return $Command.Source + } + } + + $Candidates = @() + if ($env:ProgramFiles) { + $Candidates += Join-Path $env:ProgramFiles "7-Zip\7z.exe" + } + if (${env:ProgramFiles(x86)}) { + $Candidates += Join-Path ${env:ProgramFiles(x86)} "7-Zip\7z.exe" + } + if ($env:ChocolateyInstall) { + $Candidates += Join-Path $env:ChocolateyInstall "bin\7z.exe" + } + + foreach ($Candidate in $Candidates) { + if ($Candidate -and (Test-Path $Candidate)) { + return $Candidate + } + } + + return $null +} + +function Invoke-DownloadFile { + param( + [string]$Url, + [string]$Output + ) + + $Curl = Get-Command curl.exe -ErrorAction SilentlyContinue + if ($Curl) { + & $Curl.Source --fail --location --retry 8 --retry-all-errors --retry-delay 5 --retry-max-time 300 --connect-timeout 30 --output $Output $Url + if ($LASTEXITCODE -ne 0) { + throw "curl failed to download $Url with exit code $LASTEXITCODE" + } + } + else { + $LastError = $null + for ($Attempt = 1; $Attempt -le 8; $Attempt++) { + try { + Invoke-WebRequest -Uri $Url -OutFile $Output -TimeoutSec 300 + return + } + catch { + $LastError = $_ + if ($Attempt -eq 8) { + break + } + + Start-Sleep -Seconds 5 + } + } + + throw "Invoke-WebRequest failed to download $Url after 8 attempts: $LastError" + } +} + +function Expand-TarBz2 { + param( + [string]$Archive, + [string]$Destination + ) + + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + + $SevenZip = Find-SevenZip + if ($SevenZip) { + $Stage = Join-Path $Destination "_stage" + New-Item -ItemType Directory -Force -Path $Stage | Out-Null + + & $SevenZip x -y -bb0 "-o$Stage" $Archive | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "7-Zip failed to decompress $Archive with exit code $LASTEXITCODE" + } + + $TarArchive = Get-ChildItem -Path $Stage -File -Filter "*.tar" | Select-Object -First 1 + if (-not $TarArchive) { + throw "7-Zip did not produce a .tar payload from $Archive" + } + + & $SevenZip x -y -bb0 "-o$Destination" $TarArchive.FullName | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "7-Zip failed to extract $($TarArchive.Name) with exit code $LASTEXITCODE" + } + + Remove-Item -Recurse -Force $Stage + return + } + + $Tar = Get-Command tar.exe -ErrorAction SilentlyContinue + if (-not $Tar) { + throw "7-Zip or tar.exe is required to extract micromamba on Windows" + } + + & $Tar.Source -xjf $Archive -C $Destination | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "tar.exe failed to extract $Archive with exit code $LASTEXITCODE" + } +} + +function Get-Micromamba { + param([string]$TempDir) + + foreach ($Name in @("micromamba.exe", "micromamba")) { + $Command = Get-Command $Name -ErrorAction SilentlyContinue + if ($Command) { + return $Command.Source + } + } + + $Url = "https://micro.mamba.pm/api/micromamba/win-64/$MambaVersion" + $Archive = Join-Path $TempDir "micromamba.tar.bz2" + $ExtractDir = Join-Path $TempDir "micromamba" + + Write-Host "Downloading micromamba for win-64" + Invoke-DownloadFile -Url $Url -Output $Archive + Expand-TarBz2 -Archive $Archive -Destination $ExtractDir + + $MambaBin = Join-Path $ExtractDir "Library\bin\micromamba.exe" + if (-not (Test-Path $MambaBin)) { + throw "micromamba.exe not found at $MambaBin after extraction" + } + + return $MambaBin +} + +function Test-LlvmInstall { + Repair-LibclangForBindgen + + if (-not (Test-Path $LlvmConfig)) { + return $false + } + + if (-not (Test-Path $Libclang)) { + return $false + } + + try { + $FoundVersion = (& $LlvmConfig --version).Trim() + if (-not $FoundVersion.StartsWith($RequiredVersion)) { + return $false + } + + $LibDir = (& $LlvmConfig --libdir).Trim() + if (-not (Test-Path $LibDir)) { + return $false + } + + Repair-LlvmSystemLibsForMsvc + + $StaticLibs = (& $LlvmConfig --libnames --link-static).Trim() + if ([string]::IsNullOrWhiteSpace($StaticLibs)) { + return $false + } + + $SystemLibs = (& $LlvmConfig --system-libs --link-static).Trim() + foreach ($LibName in @("z.lib", "zstd.dll.lib", "xml2.lib")) { + if ($SystemLibs -match "(^|\s)$([regex]::Escape($LibName))($|\s)") { + $ImportLib = Join-Path $LlvmLib $LibName + if (-not (Test-Path $ImportLib)) { + return $false + } + } + } + } + catch { + return $false + } + + return $true +} + +function Repair-LlvmSystemLibsForMsvc { + if (-not (Test-Path $LlvmLib)) { + return + } + + $ZstdDllImportLib = Join-Path $LlvmLib "zstd.dll.lib" + if (-not (Test-Path $ZstdDllImportLib)) { + foreach ($CandidateName in @("zstd.lib", "libzstd.lib")) { + $Candidate = Join-Path $LlvmLib $CandidateName + if (Test-Path $Candidate) { + Write-Host "Creating LLVM-compatible zstd.dll.lib from $CandidateName" + Copy-Item -Force -Path $Candidate -Destination $ZstdDllImportLib + break + } + } + } +} + +function Repair-LibclangForBindgen { + if (Test-Path $Libclang) { + return + } + + if (-not (Test-Path $LlvmBin)) { + return + } + + $PackagedLibclang = Get-ChildItem -Path $LlvmBin -File -Filter "libclang-*.dll" | + Sort-Object Name | + Select-Object -First 1 + + if (-not $PackagedLibclang) { + return + } + + Write-Host "Creating bindgen-compatible libclang.dll from $($PackagedLibclang.Name)" + Copy-Item -Force -Path $PackagedLibclang.FullName -Destination $Libclang +} + +function Write-LlvmDiagnostics { + Write-Host "LLVM prefix: $LlvmPrefix" + if (Test-Path $LlvmConfig) { + & $LlvmConfig --version + & $LlvmConfig --shared-mode + & $LlvmConfig --libdir + & $LlvmConfig --libnames --link-static core + & $LlvmConfig --system-libs --link-static + } + else { + Write-Host "llvm-config.exe not found at $LlvmConfig" + } + + if (Test-Path $Libclang) { + Write-Host "Found libclang: $Libclang" + } + else { + Write-Host "libclang.dll not found at $Libclang" + if (Test-Path $LlvmBin) { + Get-ChildItem -Path $LlvmBin -File -Filter "*clang*.dll" | + ForEach-Object { Write-Host " candidate: $($_.FullName)" } + } + } +} + +if (Test-LlvmInstall) { + $FoundVersion = (& $LlvmConfig --version).Trim() + Write-Host "conda-forge LLVM $FoundVersion already installed at $LlvmPrefix" + exit 0 +} + +if (Test-Path $InstallDir) { + Write-Host "Removing invalid or incompatible LLVM environment at $InstallDir" + Remove-Item -Recurse -Force $InstallDir +} + +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $InstallDir) | Out-Null +New-Item -ItemType Directory -Force -Path $MambaRoot | Out-Null + +$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) "pecos-micromamba-$([System.Guid]::NewGuid())" +New-Item -ItemType Directory -Force -Path $TempDir | Out-Null + +try { + $MambaBin = Get-Micromamba -TempDir $TempDir + + Write-Host "Installing conda-forge LLVM $Version, clang $Version, libclang $Version, zlib, and libxml2-devel to $InstallDir" + $OldMambaRoot = $env:MAMBA_ROOT_PREFIX + $env:MAMBA_ROOT_PREFIX = $MambaRoot + try { + & $MambaBin create ` + -y ` + -p $InstallDir ` + --override-channels ` + -c conda-forge ` + "llvmdev=$Version" ` + "clang=$Version" ` + "libclang=$Version" ` + "zlib" ` + "libxml2-devel" + if ($LASTEXITCODE -ne 0) { + throw "micromamba failed to create LLVM environment with exit code $LASTEXITCODE" + } + } + finally { + if ($null -eq $OldMambaRoot) { + Remove-Item Env:MAMBA_ROOT_PREFIX -ErrorAction SilentlyContinue + } + else { + $env:MAMBA_ROOT_PREFIX = $OldMambaRoot + } + } + + if (-not (Test-LlvmInstall)) { + Write-LlvmDiagnostics + throw "conda-forge LLVM install did not provide usable LLVM $RequiredVersion static libraries and libclang" + } + + Write-LlvmDiagnostics + Write-Host "Installed conda-forge LLVM $Version to $LlvmPrefix" +} +finally { + if (Test-Path $TempDir) { + Remove-Item -Recurse -Force $TempDir + } +} diff --git a/scripts/ci/unsafe-allowlist.txt b/scripts/ci/unsafe-allowlist.txt index 4282e3f9f..3bbec7c13 100644 --- a/scripts/ci/unsafe-allowlist.txt +++ b/scripts/ci/unsafe-allowlist.txt @@ -7,6 +7,7 @@ crates/pecos-cuquantum crates/pecos-cuquantum-sys crates/pecos-foreign crates/pecos-gpu-sims +crates/pecos-hugr-qis crates/pecos-ldpc-decoders crates/pecos-llvm crates/pecos-pymatching @@ -15,6 +16,10 @@ crates/pecos-qis-ffi crates/pecos-simulators crates/pecos-tesseract exp/pecos-experimental +# zlup revival (experimental): zlup contains one reviewed `unsafe fn new_unchecked` +# invariant constructor; zlup-ffi is a C-ABI boundary crate (Box::from_raw / slices). +exp/zlup +exp/zlup/ffi/zlup-ffi go/pecos-go-ffi julia/pecos-julia-ffi python/pecos-rslib diff --git a/scripts/win-msvc-bootstrap.ps1 b/scripts/win-msvc-bootstrap.ps1 index 01b4a3ad5..c53cd5d54 100644 --- a/scripts/win-msvc-bootstrap.ps1 +++ b/scripts/win-msvc-bootstrap.ps1 @@ -22,7 +22,7 @@ # # This script is the ONLY writer of the `[target.x86_64-pc-windows-msvc]` table # and the MSVC subset of `[env]` (LIB/INCLUDE/LIBPATH). It does a *scoped* merge -# so it never disturbs the keys the Rust writers own (LLVM_SYS_140_PREFIX, +# so it never disturbs the keys the Rust writers own (LLVM_SYS_211_PREFIX, # CUQUANTUM_ROOT) or any other table, and never emits a duplicate `[env]`. # # Inert (exit 0) on non-Windows so it is safe as an unconditional just prereq. diff --git a/uv.lock b/uv.lock index 103435b89..0e8d48345 100644 --- a/uv.lock +++ b/uv.lock @@ -195,7 +195,7 @@ wheels = [ [[package]] name = "black" -version = "26.3.1" +version = "26.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -207,34 +207,34 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, - { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] [[package]] @@ -256,11 +256,11 @@ css = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -461,14 +461,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -571,7 +571,7 @@ resolution-markers = [ "(python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'darwin')", ] dependencies = [ - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -650,115 +650,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, - { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, - { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, - { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, - { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, - { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, - { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, - { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, - { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, - { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, - { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, - { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, - { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, - { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, - { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, - { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, - { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, - { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, - { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [package.optional-dependencies] @@ -805,7 +805,7 @@ wheels = [ [[package]] name = "cuda-bindings" -version = "13.2.0" +version = "13.3.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", @@ -817,24 +817,24 @@ dependencies = [ { name = "cuda-pathfinder", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/fe/7351d7e586a8b4c9f89731bfe4cf0148223e8f9903ff09571f78b3fb0682/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b395f79cb89ce0cd8effff07c4a1e20101b873c256a1aeb286e8fd7bd0f556", size = 5744254, upload-time = "2026-03-11T00:12:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ef/184aa775e970fc089942cd9ec6302e6e44679d4c14549c6a7ea45bf7f798/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6f3682ec3c4769326aafc67c2ba669d97d688d0b7e63e659d36d2f8b72f32d6", size = 6329075, upload-time = "2026-03-11T00:12:32.319Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ea/81999d01375645f34596c76eb046b4b36d58cc6fe2bddb2410f8a7b7a827/cuda_bindings-13.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:845025438a1b9e20718b9fb42add3e0eb72e85458bcab3eeb80bfd8f0a9dab33", size = 5600047, upload-time = "2026-03-11T00:12:34.848Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" }, - { url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" }, - { url = "https://files.pythonhosted.org/packages/29/5a/0ce1731c48bcd9f40996a4ef1abbf634f1a7fe4a15c5050b1e75ce3a7acf/cuda_bindings-13.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:debb51b211d246f8326f6b6e982506a5d0d9906672c91bc478b66addc7ecc60a", size = 5631363, upload-time = "2026-03-11T00:12:41.58Z" }, - { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, - { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a5/d7f01a415e134546248cef612adad8153c9f1eb10ec79505a7cd8294370b/cuda_bindings-13.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:45815daeb595bf3b405c52671a2542b1f8e9329f3b029494acbfcc74aeaa1f2d", size = 5840830, upload-time = "2026-03-11T00:12:48.43Z" }, - { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" }, - { url = "https://files.pythonhosted.org/packages/c4/84/d3b6220b51cbc02ca14db7387e97445126b4ff5125aaa6c5dd7dcb75e679/cuda_bindings-13.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8cebe3ce4aeeca5af9c490e175f76c4b569bbf4a35a62294b777bc77bf7ac4d8", size = 5796512, upload-time = "2026-03-11T00:12:54.483Z" }, - { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" }, - { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" }, - { url = "https://files.pythonhosted.org/packages/e3/73/98bcb069778fe420226db75aff54b5dd6c3ecfd0912edabab723326e80b7/cuda_bindings-13.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd658bb5c0e55b7b3e5dd0ed509c6addb298c665db26a9bfba35e1e626000ba2", size = 5938605, upload-time = "2026-03-11T00:13:01.639Z" }, - { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" }, - { url = "https://files.pythonhosted.org/packages/52/49/4e01cc06447d39476e138d1b1adec8d35c0d04eccd2c8d69befc08cd66e8/cuda_bindings-13.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ccf14e0c1def3b7200100aafff3a9f7e210ecb6e409329e92dcf6cd2c00d5c7", size = 6662637, upload-time = "2026-03-11T00:13:07.881Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1f/5ef51f5fbaa5d4d3201bb3d7555af028ec1aa4416275ccbf73c9e34e3d2d/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0", size = 6675244, upload-time = "2026-05-29T23:11:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/fc/64/bb17e4d168569ef7be05c44474fe3dc19278d60a69ba228e45a431c86444/cuda_bindings-13.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:c0c4b1a995098c46695c24257a342dc97d6e6d3f3050b944c9f43bd26d734051", size = 5625597, upload-time = "2026-05-29T23:11:40.808Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/93/f7/0e35987a21914f84068061dcf4b61466ccbce1c62ddc9727596d5ed0c26f/cuda_bindings-13.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:507b0e19e7f934c5e30f30f0244ad70a75812619a7d3a0d742543caae1bd50f1", size = 5664286, upload-time = "2026-05-29T23:11:47.719Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/7c/95/872a0392122f1fb43fcb06869790ef3171f37beee9f7db8f441739113570/cuda_bindings-13.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:b134dd8c5c66ae4c4ad814f7aee88fd215353c077010cbc47e3b55ed35ec9eff", size = 5875099, upload-time = "2026-05-29T23:11:54.635Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2f/6a0dd496550c6fafbf6aeb1bf40242eeabb2fd138a43892aabb4be8224c2/cuda_bindings-13.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:18c8c167c8907b8f02531ca810534315c458dabef31f7965095619bf647b9202", size = 5830027, upload-time = "2026-05-29T23:12:01.205Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/2734be44dbc80ac082ec23a86b41c8294992dcb90033645ed1bc50aafe4c/cuda_bindings-13.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:8de12ef60bf40756852cb62bbb40460609269f6ece522903d1cc93d73a3ececb", size = 5961055, upload-time = "2026-05-29T23:12:07.971Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/27/2a/b59bcac016ab9985d6b48a5d05b0d698461a159ca03ee11c4abd54da2ac4/cuda_bindings-13.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61120b5e4f4a63f67efd7e7396914cb9ef871bb1f0021e990fb70277be240a4d", size = 6740329, upload-time = "2026-05-29T23:12:15.153Z" }, ] [[package]] @@ -843,7 +843,7 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/b3/b2/c9bb66a400baae14d5cd25cca9e7cf69f9328543edb37efa6e7ba1088cc7/cuda_core-0.7.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8feaf99613cd1d025b26374b1c92ff72d9e8078a3236fc78aa550ff3b0028e3", size = 28962792, upload-time = "2026-04-08T17:03:00.541Z" }, @@ -868,10 +868,10 @@ wheels = [ [[package]] name = "cuda-pathfinder" -version = "1.5.4" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/d0/c177e29701cf1d3008d7d2b16b5fc626592ce13bd535f8795c5f57187e0e/cuda_pathfinder-1.5.4-py3-none-any.whl", hash = "sha256:9563d3175ce1828531acf4b94e1c1c7d67208c347ca002493e2654878b26f4b7", size = 51657, upload-time = "2026-04-27T22:42:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, ] [[package]] @@ -924,7 +924,7 @@ version = "14.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/dd/18/8ec57a901a11d6955f90e1fbf3e04c8f26721066c99dfa25276e3e3b1f1d/cupy_cuda12x-14.1.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:909c4b8ac05eee43edfbe791522ee5d593e3504be7bd5c20e2de12b050db2a26", size = 143787561, upload-time = "2026-06-01T04:51:46.125Z" }, @@ -948,28 +948,30 @@ wheels = [ [[package]] name = "cupy-cuda13x" -version = "14.0.1" +version = "14.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/9d/5f271070c17aac8e30b140b5ec9c129983f57b49899a8fc34c3f114d09f7/cupy_cuda13x-14.0.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:4d6d5ae87eb1a6eb55f5a3d54c606f5263c54319c3d85afe4627cb7fff403050", size = 72993295, upload-time = "2026-02-20T10:23:48.346Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/bd6867acfa356c822971d90792e814fce1fcfc54baaa20b1cadb8eec5c52/cupy_cuda13x-14.0.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:3d292c204eb7a2cfe04698b26de44b4173a1c576f603d6da9f0b0b6444225a63", size = 68923997, upload-time = "2026-02-20T10:23:53.046Z" }, - { url = "https://files.pythonhosted.org/packages/c5/62/25a256acdc0127c70153799a3b094adbeee11630e3be118f8a840415cafc/cupy_cuda13x-14.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:a3e34e0b29e09a4f80c9c1fef963cd75540fb003cbbb6183ac3f784a8a02aef3", size = 35450515, upload-time = "2026-02-20T10:23:56.602Z" }, - { url = "https://files.pythonhosted.org/packages/37/a2/84f2a9739e914cb119807b8b91d48b2c7628794e4eee66daa3402a2d7442/cupy_cuda13x-14.0.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:5c368d7b9fa9eef8a5d209d6335f468f84893cf875defdd1d195b6b27da941c9", size = 74276076, upload-time = "2026-02-20T10:24:00.841Z" }, - { url = "https://files.pythonhosted.org/packages/5b/fe/064f73bab30460eb9024e1a52319b8b69ef0c1cd853301189d1b4579e686/cupy_cuda13x-14.0.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:97fe8f0ff683ed5f39b82d0e56ad90368fc001c1b191a3bc662d31a0ac8cb482", size = 70267352, upload-time = "2026-02-20T10:24:05.105Z" }, - { url = "https://files.pythonhosted.org/packages/df/3c/a0de0513ad575f3a9f9543efd9ae408303101205c206d9cfe5a37b59bddc/cupy_cuda13x-14.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9dda18b7100a19a54536b4325e63a29c1fb58891a0973623c6c8c80642b3f933", size = 35450158, upload-time = "2026-02-20T10:24:09.205Z" }, - { url = "https://files.pythonhosted.org/packages/7d/9b/7983b4e24749937dd4ab34565561a8c015e88df4ff9fd4678337b710b3ee/cupy_cuda13x-14.0.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:763f33f5641a28db5ac91788301271930e910813f1b1279119b504babeb1b863", size = 73616635, upload-time = "2026-02-20T10:24:14.115Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d4/42f79f6baea881c604982df18d158456e40071925bfdd53c1b6eb82f6e75/cupy_cuda13x-14.0.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:a06cdc0d6a0054663fca4770e32df6a39cbeb7396a08b23f97965e5e1c0edb7d", size = 69793920, upload-time = "2026-02-20T10:24:20.287Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0c/83c6be011fa00a270c0d08985844fd992d59c34a6bb91755dad4f31942e8/cupy_cuda13x-14.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:e91052340bab0860bdf1fd4e5a4fc85846800df6cc9a1de02a5afa1dd8655550", size = 35353554, upload-time = "2026-02-20T10:24:24.337Z" }, - { url = "https://files.pythonhosted.org/packages/6e/de/679a7a571dcd1b654378cc4f9c5cd6f6d4af09ec8973eb4b2dce276adce4/cupy_cuda13x-14.0.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:a8488a6a9c934c417f98078ac7dd2a47059c39195199b7e557c84d01b3071c75", size = 73167833, upload-time = "2026-02-20T10:24:30.645Z" }, - { url = "https://files.pythonhosted.org/packages/e4/05/f60525718e83c0ea10efc4ae3183ea9c5309935b76c200b6d9c2569185de/cupy_cuda13x-14.0.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:1c206483aaea40cd38bfbd1a29f4df0c19d555d306ffe43ef16f39db0e7e7a7f", size = 69416450, upload-time = "2026-02-20T10:24:35.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/8b4b996690d91745e34ce6103fbf8a9fb51f2e46b4e47c45ea8b3fff9d2b/cupy_cuda13x-14.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cd01307b4f67e3bf242537f02ac8e5db729a557510728f70ee41a32490ce1e1a", size = 35336403, upload-time = "2026-02-20T10:24:40.355Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f9/e5480aefcc86cfa45abe12c323e8e65b8a04727c227f67dff1cae99ead1b/cupy_cuda13x-14.0.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:70a5b3d4b20367abf978e2c75bd0235f0768b5901a6102398877aef044628151", size = 73012147, upload-time = "2026-02-20T10:24:45.12Z" }, - { url = "https://files.pythonhosted.org/packages/61/9e/cd8ddc220283272a7891a8277fb911a584a9224bd1c8f56d75ca6f62d976/cupy_cuda13x-14.0.1-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:2e6fbfb24bc336ba91507e9e6488589665a4c7366453bb80c717d874fce3c373", size = 68714185, upload-time = "2026-02-20T10:24:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f5/273193563cdc37cdb22de3b73e7db12819b39fafb73de6bcf7d48f20945e/cupy_cuda13x-14.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:22b50139e05c4612fac905dd6c3390f8687e0e390f0e200d5be14be1726e3d04", size = 35474838, upload-time = "2026-02-20T10:24:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ce/71352ce895400844df26553e3d723dd64754d19b657056fc52553ad6b725/cupy_cuda13x-14.1.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:ae3a75a7e9510fb03251420d85b4efa48187ee5fd48b08c88b7a0f0620bd779a", size = 72670263, upload-time = "2026-06-01T04:53:40.802Z" }, + { url = "https://files.pythonhosted.org/packages/5f/24/6143d51d9fbcd1aad63759be7840f6e0e4890bf7bfa1173a39689031cf33/cupy_cuda13x-14.1.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:223a9e1e6db911002ca5b94c348a3ed5c1d7869ba4dd6524ab3d0263123b2248", size = 68695025, upload-time = "2026-06-01T04:53:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/1b3dadb01306b3824c72b6e7da1d7cce5e2b3b7fcf901ce00364ff2a3059/cupy_cuda13x-14.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:2a656fbcdc1ca1c09b039f9ec6540e8d18e77ed66e92ae275a82b4d849bcc185", size = 35291771, upload-time = "2026-06-01T04:53:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/35/a0/33ba64feccedf661960057a9967e2e623853f4757ba5be2f04f6503a4ac0/cupy_cuda13x-14.1.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:3d12e1020066a699f5e27f4c5597167dca042704c0ed1db74066c32c0c2dbfb1", size = 73970864, upload-time = "2026-06-01T04:53:53.903Z" }, + { url = "https://files.pythonhosted.org/packages/c1/99/d1add95f15f3ad0dec954688c012e97f991363c4a74ca970fc0ddb217534/cupy_cuda13x-14.1.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:424caa906c16f31328558151dd5e0596891eda7644f40ca9348c3eb14af084d1", size = 70032752, upload-time = "2026-06-01T04:54:02.944Z" }, + { url = "https://files.pythonhosted.org/packages/30/48/7a9fbc1184bb6288e173903984efbe2dbf9a29308b810d26ecac963d3690/cupy_cuda13x-14.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:0b9e6f6110cbf039dd75159f8adc99c4fb84cbcabf3025c6607e3864b09ba469", size = 35289510, upload-time = "2026-06-01T04:54:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/8e/df/33e14f4648910db90d9aa3ac03c7012eec2e5d05a7ab69ec74a7864d6354/cupy_cuda13x-14.1.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:9226f3279c64aa6f07a79ba0081f3275a99a36c2db3f836f4b687dd8d77fa8bc", size = 73276230, upload-time = "2026-06-01T04:54:10.208Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a7/7105dad3285c35451aae7efcc0a09e5b114de25065e3971552d906354e3e/cupy_cuda13x-14.1.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:a57c3eda202ffd70e34b9031a88f17c810a75e816398b4028360027cb3a4cd4d", size = 69532519, upload-time = "2026-06-01T04:54:14.589Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/6f20833eedbfd1d44925780a2fdffaf72b3c78145a228359c7f2d534e0a8/cupy_cuda13x-14.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:c0ce59e54da561eeaebef0460242cfd159518d63b388d9d566d198d1d7614634", size = 35192036, upload-time = "2026-06-01T04:54:17.885Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0f/149a9e6c561f44e6522be6c3fdacdaed17d8fd5a1651004a0782ce045502/cupy_cuda13x-14.1.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:cc671cb766f1c42cb6eed3bf27e9da6092721bcb3d77e09aef28e2f5b089ea38", size = 72811428, upload-time = "2026-06-01T04:54:22.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f6/f4cd043ad8c8e18410e9cea73a8de8778a0a05c705b8505729a794758bf1/cupy_cuda13x-14.1.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:ff4526725c1398ea19d578044737aa24859b08e1f469aba0ca45dfd6f9aa505d", size = 69094726, upload-time = "2026-06-01T04:54:26.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/64/f2b60a0f4d73820d774906bf8684df0c8494820d78b3494121c7ec57ee25/cupy_cuda13x-14.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:54949b4e4f90e3588eeef25b55037f156578364e4835eb9ae1b93e236f2e58d5", size = 35171412, upload-time = "2026-06-01T04:54:31.313Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/9bc8d6d40003640653161e2dba78740f2055df8bb48e216eed5584cecc3c/cupy_cuda13x-14.1.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:8b068f6958811bc50d81bd0a83267ec4cffc3d7e842120b9646ec547f5a69298", size = 72680680, upload-time = "2026-06-01T04:54:35.077Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/8c79c577e9b35c60e5600217d1a849ab76c18a289f99a4145aa85388a531/cupy_cuda13x-14.1.1-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:39f8aeeb5bad16603956f8ec00094e058a9dd5db92ab514975686750363c3692", size = 68432306, upload-time = "2026-06-01T04:54:38.882Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/8d4d77138eb5f60a2a1fb5484a89dad2bc6bc6283682179994d722cfb4b5/cupy_cuda13x-14.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:dae6f77edfe96ab8e014d3e589dca33906d212fdf8f72e66850d4612bc031783", size = 35323057, upload-time = "2026-06-01T04:54:42.541Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c4/eb46fec30fffd7a57a80cc7f027e50a1d530b332e81362a8b874905da2d1/cupy_cuda13x-14.1.1-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:509f378c865c0b6bc9ed1dfe9e94b08b4511e9865363fdac55f9d66fe341e91d", size = 72984246, upload-time = "2026-06-01T04:54:46.464Z" }, + { url = "https://files.pythonhosted.org/packages/24/59/e0098ca2e1ba99a0086ad4b515b2d7dbb8ae0f13b9721c02eed0db4adec5/cupy_cuda13x-14.1.1-cp314-cp314t-manylinux2014_x86_64.whl", hash = "sha256:6c0d19789e9f9515988d51dffaff70af8b3dcdddcdf2fbafa9eff08df7be0a51", size = 68662887, upload-time = "2026-06-01T04:54:50.932Z" }, ] [[package]] @@ -984,7 +986,7 @@ dependencies = [ { name = "custabilizer-cu12", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "custatevec-cu12", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "cutensornet-cu12", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "nvmath-python", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] wheels = [ @@ -1001,14 +1003,14 @@ name = "cuquantum-python-cu13" version = "26.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", version = "13.2.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "cuda-bindings", version = "13.3.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "cudensitymat-cu13", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "cupauliprop-cu13", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "cupy-cuda13x", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "custabilizer-cu13", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "custatevec-cu13", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "cutensornet-cu13", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "nvmath-python", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] wheels = [ @@ -1140,11 +1142,11 @@ wheels = [ [[package]] name = "decorator" -version = "5.2.1" +version = "5.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] [[package]] @@ -1206,59 +1208,59 @@ wheels = [ [[package]] name = "fonttools" -version = "4.62.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, - { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, - { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, - { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, - { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, - { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, - { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, - { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, - { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, - { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, - { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, - { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, - { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, - { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, - { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, - { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, - { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, - { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, - { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, - { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, - { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] [[package]] @@ -1302,34 +1304,38 @@ wheels = [ [[package]] name = "guppylang" -version = "0.21.6" +version = "0.21.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "guppylang-internals" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "pytket" }, { name = "selene-hugr-qis-compiler" }, { name = "selene-sim" }, { name = "tqdm" }, { name = "types-tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/45/0c4cb644d10682fdf9de492911ef55ddc571469270487058655fe9e233b4/guppylang-0.21.6.tar.gz", hash = "sha256:57cdfa0c2fe7ffd80c193c830505285e0a8c779a6f817016cb8e295eaa1cae19", size = 60477, upload-time = "2025-10-30T17:37:21.435Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/79/e148d43eebdbd0e670cfe983d2806c1d8199d4b69e45680a352d2a6c6265/guppylang-0.21.16.tar.gz", hash = "sha256:37ecb3267734540104ada3be1e12726f631edfba2b013efa8f8064cc324d7fda", size = 70378, upload-time = "2026-06-04T14:57:10.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/81/3c0fffa0c28bd2bcaf37dd053d50017f8afc3794de13d9e60b7f5b9a5dab/guppylang-0.21.6-py3-none-any.whl", hash = "sha256:ecc03bf0c1f2d1146a3fdcc2e75a9a775eb11fd37b128a1febf319ab6b709619", size = 58427, upload-time = "2025-10-30T17:37:20.212Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6a/7120b688f8543ef39ac1d42d325233aacde677a562fe72e3e51f10e433ee/guppylang-0.21.16-py3-none-any.whl", hash = "sha256:22ada5f013d96574102b89d88c290449b23f9972a8f0e88e92227308b8aa275c", size = 67031, upload-time = "2026-06-04T14:57:09.183Z" }, ] [[package]] name = "guppylang-internals" -version = "0.25.0" +version = "0.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, + { name = "pytket" }, + { name = "tket", extra = ["pytket"] }, { name = "tket-exts" }, { name = "typing-extensions" }, + { name = "wasmtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/d1/1d71187602922cc70a4880137d65ea742a3f3a37be9519e23c4c5f28bc14/guppylang_internals-0.25.0.tar.gz", hash = "sha256:abbcbccb44f3404adff52046907c8c1ee993b8d779fcbef259b7bd8840c450d1", size = 180215, upload-time = "2025-10-29T15:13:20.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/67/c2ea5658a28d1ea814a64e91cf0433197dd0e99a3eab1dc3538e14499634/guppylang_internals-0.36.1.tar.gz", hash = "sha256:3ee67ffdd7e97cf9273f9a65697c48fb192bb98f110c960931d6ea1f2b7f0934", size = 214605, upload-time = "2026-06-04T14:53:42.617Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/d1/48129590b4eb5e99deff8e3ddf3a039a66d96523dfb68592a6267b779b30/guppylang_internals-0.25.0-py3-none-any.whl", hash = "sha256:95473e5ef93329fc6a764fce82e0ef3931b691407a10a21520c522080405565b", size = 233971, upload-time = "2025-10-29T15:13:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/31/ac/f66326c93cc7e0c74ec8a195d23c78690cb470c77519e77a4d4876f5d355/guppylang_internals-0.36.1-py3-none-any.whl", hash = "sha256:d70dc644bdfaff46b5b94b308aa90e4aa667c60b85f72a82fbc1ea729d2dfe51", size = 267518, upload-time = "2026-06-04T14:53:40.954Z" }, ] [[package]] @@ -1371,48 +1377,45 @@ wheels = [ [[package]] name = "hugr" -version = "0.14.4" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "graphviz" }, { name = "pydantic" }, { name = "pydantic-extra-types" }, - { name = "pyzstd" }, { name = "semver" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/12/6e653fb7ea01567a220f4fe0b3ae9e435f1e4d8d052d7fc6089195b1322e/hugr-0.14.4.tar.gz", hash = "sha256:0f39810cde20aa22741ceec236ce8936a50cf16b72b259e561fd205a8c02ea08", size = 1076949, upload-time = "2025-11-26T16:15:07.355Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/f9/3af5afddee55a5c42fc2b3ccd85a7c23b284f6570822a0171a1157622866/hugr-0.14.4-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:93c9c56ddc0b5e66e4a6a19f95e94f95c2ce764c2763a8abf3329fb1c12c7cdd", size = 3831727, upload-time = "2025-11-26T16:14:50.634Z" }, - { url = "https://files.pythonhosted.org/packages/04/18/b854c36821819a0f96fdcbf6849f0b29a057e52bda101c2081f705dfeb64/hugr-0.14.4-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:196d1c4eafa4b86502267809b316fcc9d9f5bd30a9052991ee4cd9140a99a969", size = 3406301, upload-time = "2025-11-26T16:14:46.918Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/335abcb77657074c7bd1d32fe78acf4bfb86243b84ca4537006aa2ed88ad/hugr-0.14.4-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8affb8819f864dcbc7b9a32bb9efbc66d45b6656d5446afb2078cf1c1c15b63", size = 3719565, upload-time = "2025-11-26T16:14:13.276Z" }, - { url = "https://files.pythonhosted.org/packages/b7/92/e048e852e58d948c9a3d84daf8b5f124642050a7a200e22e0923025d6dd9/hugr-0.14.4-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1bc1960987198c024300b8baf5415bda825a786329fe7d9c4b5bf4c798809d8c", size = 3774829, upload-time = "2025-11-26T16:14:20.189Z" }, - { url = "https://files.pythonhosted.org/packages/df/3d/a537e46cf2777538b33664150a36de5688ad9b79285765d3f47c1e208319/hugr-0.14.4-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d39ea3725047505458d63a5f3f9e1b7ea248fa62f8fab3a94c3ee5fefde36197", size = 4032332, upload-time = "2025-11-26T16:14:40.093Z" }, - { url = "https://files.pythonhosted.org/packages/92/01/108efd9b71dcf3c80863c1d12bcb39eaff1052472b62b0ff06cf73607f76/hugr-0.14.4-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfcd7656edbf746c49a28391ffadd995eabdeeb47c79b4b6d286996d175a67f6", size = 4187215, upload-time = "2025-11-26T16:14:26.906Z" }, - { url = "https://files.pythonhosted.org/packages/16/23/b4699cbf9e50d7657ce7e179d873980e25416ca2a56c7d97234cc9528bc2/hugr-0.14.4-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbe48b812fe2f2c0eeb8ffc38bca08d34d88449f58eb139e77b5fc0c5a3e73c0", size = 4383840, upload-time = "2025-11-26T16:14:34.108Z" }, - { url = "https://files.pythonhosted.org/packages/37/c9/081bb993bdf097a67b7ec9292cd5027f940666972d8631580ab346ed547a/hugr-0.14.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdb23f2b40e56875752ed739bf1c8bc3ae14bbdacde298e1c7ed0641b27ff853", size = 4068208, upload-time = "2025-11-26T16:14:43.755Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8b/4f6ae5ca60115afc3a925b197c3504143a454cbfb84615abe7e6a4c666f4/hugr-0.14.4-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:772d631a6ad5099021f2d95d45ade7c543889d49b36d1b03fc072d3da3caa9a2", size = 3942511, upload-time = "2025-11-26T16:14:53.466Z" }, - { url = "https://files.pythonhosted.org/packages/11/5e/851c84aa0b15779318f57c25d19aaca46e103efb982238c7ad6771a0d49e/hugr-0.14.4-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c7fc26b710141d521951be5b5d85ec4fa0d583ca09b4c6a7e4d1e0d18864072d", size = 4053248, upload-time = "2025-11-26T16:14:56.593Z" }, - { url = "https://files.pythonhosted.org/packages/c6/fc/38ca1caec06bb019fc3ed8a9f86f96ce0a26db7d0ca1496cf13d96ab51c0/hugr-0.14.4-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b0f0455ec23fbc60dc9a5770c7c996baa544d96547622de0ba3e9ae4f27ab667", size = 4078485, upload-time = "2025-11-26T16:15:00.721Z" }, - { url = "https://files.pythonhosted.org/packages/bb/eb/6c851799c7cf5899253178928e6579322c50793b23abf152c093a4d9952f/hugr-0.14.4-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a40bda1a592a02560d8735cc1f0514e04dfc4d6fdd95ece8a63819708b3e6b12", size = 4265564, upload-time = "2025-11-26T16:15:04.257Z" }, - { url = "https://files.pythonhosted.org/packages/9f/17/cecf6b74c6896ae85ed98962a69c651f988bd7208c437c68a75ff5846114/hugr-0.14.4-cp310-abi3-win32.whl", hash = "sha256:8b930de9755cf8fa649775484a3ab360271fdd29a1aa0ec5570204e80824a71a", size = 3378991, upload-time = "2025-11-26T16:15:10.638Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2a/9a56a0da944ce7daf754cac339270acb47a15d68c7bc84f3df366be64eb5/hugr-0.14.4-cp310-abi3-win_amd64.whl", hash = "sha256:1987f8b5078dc40b674203acb5952d02f1b72728997123652a01f5fface882a0", size = 3606113, upload-time = "2025-11-26T16:15:08.762Z" }, - { url = "https://files.pythonhosted.org/packages/28/56/caac43ee7fcc69edbc3d32ec5796ab072d973b5b21c0ce389c18e202dca6/hugr-0.14.4-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a1cdef74e8b5bd3831f5f635dbde78debb5bd3f9c997b78747542f1256d499fb", size = 3849266, upload-time = "2025-11-26T16:14:52.047Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b0/d5e3187560aa0d84044863e24a3df8cb1706ae86315510d79905c53b29da/hugr-0.14.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e9880f01550807a3fcb9acbb062c1ae9601328bfe4684580ca04d543d3131eb0", size = 3408154, upload-time = "2025-11-26T16:14:49.019Z" }, - { url = "https://files.pythonhosted.org/packages/05/ff/817a4595b68c2ef56ddfea82ec20c0247ebbcad1e721b2767ff513ece73b/hugr-0.14.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6b0a67e1740fb78d66ab31561be4d6374f480cab115f6314d6bbc8d26704a73", size = 3715760, upload-time = "2025-11-26T16:14:15.307Z" }, - { url = "https://files.pythonhosted.org/packages/54/19/53574f3dbcf6b5c3602c71d017fda201196f5ea13c365012c12e3de8d87f/hugr-0.14.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3aedae6e89aaebadfbbd201e35b07be2a683bb23307b2bbdcac410a170c0d11", size = 3771058, upload-time = "2025-11-26T16:14:21.889Z" }, - { url = "https://files.pythonhosted.org/packages/3b/45/3094b9254030a6443b83ff70114edf90dfbaf1356d387677335107509186/hugr-0.14.4-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d32813dafd772ac067d68d5bd2ed3f1a98d8912a2908db68a137ad75d4a88e7", size = 4038863, upload-time = "2025-11-26T16:14:42.014Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/4065d535076a61a9ae004b664aa8379a3c493dd768336deb927bc26f4701/hugr-0.14.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:983f26f1cf0bf196c3aca3d0f97d7ddf27ec7cc8753bea8d358b4afbe904029c", size = 4185665, upload-time = "2025-11-26T16:14:28.733Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a5/d5c5c7ad832c0b817743df9a4db58c72025c155d7e78ae12262975d2e527/hugr-0.14.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b453bc489b5c4e4746c980c935a79ada189988c10a2f77a6c2a10e04bae0b89", size = 4379657, upload-time = "2025-11-26T16:14:35.633Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7b/5083c0cc249e53082443323fe493ac6d18388f181b82d6bfa479b523b7d9/hugr-0.14.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:328eb29099b3cc1ddbc5479a6bb7e1a437f170f4e85ee27b3d35357ef2caf369", size = 4066330, upload-time = "2025-11-26T16:14:45.255Z" }, - { url = "https://files.pythonhosted.org/packages/87/e2/46cc78aff8da4544993c49638ff801a6a9a44149bf20f7e5917b18dd9fef/hugr-0.14.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1fd38be47ac27ccb9edb124a18c3b14e545708b8bbed89c8c20d1b2e14a4e9b2", size = 3939452, upload-time = "2025-11-26T16:14:54.9Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ba/f027273d8d3a6ebcfa9e6954502615050293e695dc8e38824164ef9abea1/hugr-0.14.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b59dc1972a07a2cee4d8e53d24020634d70d7d923fe0592c977e0c3de8d538ed", size = 4048708, upload-time = "2025-11-26T16:14:58.135Z" }, - { url = "https://files.pythonhosted.org/packages/aa/27/bdb89dc5ac0f1dce5e8f79a47eaa30a90aed671329107f6f305330e4e043/hugr-0.14.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:19392043a13c53bb49cfb9ffabc6a084f6a1b563a51be3bef6d4e7f78b96aee6", size = 4088139, upload-time = "2025-11-26T16:15:02.8Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e1/d1571d13f49d54cb4b84dbcc5d81e3548761b15abe019b97abdf7606c8a2/hugr-0.14.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c88b23a70ccd780f30148784eaad7cfd7384c459f5e221870d901922a6300492", size = 4259853, upload-time = "2025-11-26T16:15:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/eb/75/e49bd706b9be6005da9a838e94d2a15cd96373839ddcc88fb38b7798fd29/hugr-0.14.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c29d997d8d210ebde9d07305833cd80b2425c1419b6554042d150c314ccd1c4", size = 3719146, upload-time = "2025-11-26T16:14:16.785Z" }, - { url = "https://files.pythonhosted.org/packages/68/73/f8ec89ff697b20e244844f2bdf4aaa07b3ee063d845861659479582b0aa2/hugr-0.14.4-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57b0487a13382005d0ee0e908ae349272bdc03c3537202a0a46e9a07c4fc112c", size = 3773374, upload-time = "2025-11-26T16:14:23.602Z" }, - { url = "https://files.pythonhosted.org/packages/16/81/a82554d06dcee43b8580156c9e5ca1bafbb2eef6cb54d674b2c49bc0a21f/hugr-0.14.4-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bd82eddfcd20d26c96aa8bc8be8ab867f1c231902ae4200b570f405f0eb7ca9", size = 4185152, upload-time = "2025-11-26T16:14:30.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a8/ae3b32d52c682545b66a3e73481565050e15d47ecf374da7658e5d8f60a5/hugr-0.14.4-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea4264d9d93b9684b1b5d47a8f5d7a23e73631e4d9cbd7d34008da37f2402913", size = 4376569, upload-time = "2025-11-26T16:14:37.123Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ac/29/129d978423d9e74ee42a25a0099bea068b48e66c2d1eb4f7f5faa128fb67/hugr-0.16.0.tar.gz", hash = "sha256:de4827cfe27e9d6ee2a764382825ae7ef8bd3282947f3c59114cc0de5a198124", size = 986110, upload-time = "2026-04-01T13:04:20.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/54/c8593f1e41aedff29cd6fab66a60ffaa68380a8b8fc31841b53d1919f05c/hugr-0.16.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:270bb717d28310e297995f7e84335336d4a896b048468ca6d5a56e672e9c83f8", size = 3399386, upload-time = "2026-04-01T13:04:17.615Z" }, + { url = "https://files.pythonhosted.org/packages/9b/0d/e9d90f6895ab0a8e144f5030f2384f85a98af98dd34f616e157863b09ffc/hugr-0.16.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:759a52fd1a0e2c2a1defbddc88505294cd1ec945d422cb170e7c016c946fbcce", size = 3041962, upload-time = "2026-04-01T13:04:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/cd/41/e3ebf1fed1da84081f4e11bba9ae82a5270856288b5f38d5cbe1578bf226/hugr-0.16.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d484a7c872fb81bf359e8678def627d2f0fd64516b0e131a789d2198410117e2", size = 3362746, upload-time = "2026-04-01T13:03:43.218Z" }, + { url = "https://files.pythonhosted.org/packages/08/34/05d82ac6f872aa02cbd920927d7c1ce11163e8321fd9e053cd601bfe0d74/hugr-0.16.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e9693f512909e76abf69072b5504615cd022034ff828f9b7f30f95c51e05af74", size = 3340868, upload-time = "2026-04-01T13:03:45.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/2c/7401bb1edf930576f51e5678ae9a74d11f10c0ef83f42adf06397de2f3e1/hugr-0.16.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d66d2c07adc54065df2a244bcbc67c41c5adb9e682b8d5b3e9946bebb54d791d", size = 3776711, upload-time = "2026-04-01T13:03:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/748222a7c4c84353f8b5741240ca3c51c9e29d706884296bf44e403010cb/hugr-0.16.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e653ad5a93eaf9a9197e8d5126329ac95c4def3d878d10f5a9a9c18cebe788dd", size = 3855897, upload-time = "2026-04-01T13:03:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/eee150b3c7454ef022f1de4003ed7084af4ddebf3efadc5f6bb060306f73/hugr-0.16.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:02be6d8080372cae20c9683438e297b5f38ef1a3ab8488b34c614e575e00ee65", size = 3608483, upload-time = "2026-04-01T17:50:43.25Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/6da7d6d35db29f7782b692f9df79f5be6882d62da4067f408cdcae1dac5a/hugr-0.16.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44b750e5f725a827ac12524bd35199f956267c7ff383039cfcdb419e1ee22e31", size = 3656539, upload-time = "2026-04-01T17:50:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2e/d81e42a57de81020cb14089fe45f76337f59bc9aca9f1483e4552f3c2bee/hugr-0.16.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e8a4e6b0f17a5f56c387539cf02fb6eacbc66cbd86b5ce07b09e6a860b254ce5", size = 3569466, upload-time = "2026-04-01T13:04:00.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/0f/23845b23a6aeac0d02a89e06004dbcda286d0500314f68c73c34b07b5c78/hugr-0.16.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a087257e1d60ce6fc266a3f6d734935554a78b3dddd429698418b4f5eee49e09", size = 3617572, upload-time = "2026-04-01T13:04:03.33Z" }, + { url = "https://files.pythonhosted.org/packages/5b/43/84f8d9399fa77d04f6b4d1ef856b98b8520110f844bf1b65d6c98ac4edc1/hugr-0.16.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:8f9eaabf8439cbdf7e0d873fc10506d7f8db9673966d3d934fb8a1ec86bbc0a9", size = 3705137, upload-time = "2026-04-01T13:04:06.031Z" }, + { url = "https://files.pythonhosted.org/packages/da/9d/b3d75e12ad0265ea9cbfabb49cc9072e6109596d8777031e9a3d4d016f6d/hugr-0.16.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9781813e4222fe1498bd8adb3b31e8ce4e92e925bfd097ec24d8354b87f51d2c", size = 3881014, upload-time = "2026-04-01T13:04:08.983Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ca/d71f8374f62831472269927f2adf4b0d14e35d25769940dc05a23e8930e0/hugr-0.16.0-cp310-abi3-win32.whl", hash = "sha256:9649bf4552bbe9cf93eec21a344d50c7a4a8429aa15928e4647778a9bbf92488", size = 2954806, upload-time = "2026-04-01T13:04:13.447Z" }, + { url = "https://files.pythonhosted.org/packages/72/5a/3dbf899aacdf2978a57ccd2a5ee0832f92608b67fecf43440a1a125f0a48/hugr-0.16.0-cp310-abi3-win_amd64.whl", hash = "sha256:c75101ca5b9c846f20502d4cfa88286a2784bd7c0d653d8025040393e3a9e568", size = 3236422, upload-time = "2026-04-01T13:04:11.801Z" }, + { url = "https://files.pythonhosted.org/packages/31/23/98154df19a32bc8807d66d22ffe321f02c23fd814b225f0a5bc53c0671ac/hugr-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:c5fe2702d5e80e831c39c701b5eae3f277b089b85a92cdd55f53fba61851ecc1", size = 3399228, upload-time = "2026-04-01T13:04:18.798Z" }, + { url = "https://files.pythonhosted.org/packages/a0/73/7030d9185cf52a216f50fd2df829c0cf0ddc78205e2ca09a192f95c9b952/hugr-0.16.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:007694eb785cf0ce9eaf32dbcc94375ae8ec5c3da1938141bfa97f28e9afe0d4", size = 3042296, upload-time = "2026-04-01T13:04:16.285Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5c/ecc2637f528b14d0cf6e400978526e89c5dd51c1aa3b25da4f12b5380404/hugr-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a97b7cebec63bcc83e10747a94aa4a42a7f1b7127d56a12f8c9151014a8265c", size = 3363178, upload-time = "2026-04-01T13:03:44.559Z" }, + { url = "https://files.pythonhosted.org/packages/54/a8/1a76e227117acc914d6c53050b8c29332d2dd5f464f2bea9606550ee163c/hugr-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebaded1dbc81282fd519f29be7eab2e884f25dc60fb866b88bc4316e0998ea6a", size = 3342289, upload-time = "2026-04-01T13:03:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/de3f56e3edef9f2722071d9d48167be764d467733b4a7639d74171df4e6c/hugr-0.16.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6165273000247817467f0c97582555b96cabf2ddf667c10d9de11272e94531ae", size = 3603558, upload-time = "2026-04-01T13:03:54.839Z" }, + { url = "https://files.pythonhosted.org/packages/b4/16/faa2c8910b12d1fa71697fd5f342e5782a2ce55cbba17a9f62d0d617af17/hugr-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5be77f7840f1dde1e6e6f6d472c40f2c2726f4b20430482e76a943dc4270111e", size = 3773176, upload-time = "2026-04-01T13:03:50.302Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/f62ef65b21ff37bcd7eed15e1cdd777e940a1f2389eab670d1adac0bf062/hugr-0.16.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90c4dd63f0fa07b0fcbd5cf61b78567aa23cb5003092b571a84aae0c27fa5737", size = 3855081, upload-time = "2026-04-01T13:03:52.746Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/db53d482143e8388059eba9c9f14a128b3444b37f6fb1435c6eff0447047/hugr-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdce09aa90f305caccba667251d7fc6fcd2f469a840bd9ea810640f9cc3c3b21", size = 3641209, upload-time = "2026-04-01T13:03:57.805Z" }, + { url = "https://files.pythonhosted.org/packages/d4/72/583c458708bf156c8f69210b4bb04be19a217818e710bfec8c8933d06994/hugr-0.16.0-cp313-cp313t-manylinux_2_28_i686.whl", hash = "sha256:60fe672f0349fbc4be031e36b986e174bc68d9185ad7eeb5c3d67b71f98937be", size = 3606359, upload-time = "2026-04-01T17:50:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e4/72282c75cb607222e6aedc2fbb42cd61a8fa180d5d5a820e67682258241a/hugr-0.16.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a4bdc164c91111db8249d51126867be7ea2e774398cade7fe5432a48b5997279", size = 3650296, upload-time = "2026-04-01T17:50:57.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/12db6e42e24f0af8aad11b6aa87fdd731a0dfa53617fc879cb3bc7604f4d/hugr-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0faea63ade58f6fb051606a778fd06c397ae1cf18a1a7218123853e9ca8a6fe", size = 3569708, upload-time = "2026-04-01T13:04:02.12Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/106a5b8e618c1f4f9f501d55aae6783fb2afb77419f41a8b8b293ecfbe78/hugr-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:dceb496d1e120871c8df7d4e91f30d7ac91d19519690fd45f08c5f57e639e531", size = 3619769, upload-time = "2026-04-01T13:04:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/a608e321135e4ac0b84dd593497ee9fcd1098645884263296b42a80f695d/hugr-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c210a2bc18e65638623bb14b7c308957b7d0b9cdc8b2c8b244bc697a76966729", size = 3702898, upload-time = "2026-04-01T13:04:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/58/15/552629b919fc5d42b23f3c886bd66f33e401657346033860e5f847f5944f/hugr-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7b01e7f99b019cd77013bf4e55e0115862adf42d327c354297667ffd8023d209", size = 3873617, upload-time = "2026-04-01T13:04:10.568Z" }, ] [[package]] @@ -1439,11 +1442,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, ] [[package]] @@ -1464,7 +1467,7 @@ dependencies = [ { name = "comm" }, { name = "debugpy" }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "ipython", version = "9.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "matplotlib-inline" }, @@ -1508,7 +1511,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.13.0" +version = "9.14.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", @@ -1524,15 +1527,15 @@ dependencies = [ { name = "matplotlib-inline", marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "pexpect", marker = "(python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32') or (python_full_version < '3.11' and extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (sys_platform == 'win32' and extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (sys_platform == 'win32' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (sys_platform == 'win32' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'win32' and extra != 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-15-pecos-workspace-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'win32' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-15-pecos-workspace-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'win32' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "prompt-toolkit", marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "psutil", marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "psutil", marker = "(python_full_version >= '3.11' and sys_platform != 'emscripten') or (python_full_version < '3.11' and extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-15-pecos-workspace-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform == 'emscripten' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "pygments", marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "stack-data", marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "traitlets", marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "typing-extensions", marker = "python_full_version == '3.11.*' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/c2/c0064cf15d026501a1ef70e42efd9c3f818663089399aacc5e37a82901c1/ipython-9.14.0.tar.gz", hash = "sha256:6f27ff0f1d9ea050e0551f71568bc4b34d8aba579e8f111c5b4175f44ac6b4aa", size = 4432601, upload-time = "2026-05-29T15:13:24.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, + { url = "https://files.pythonhosted.org/packages/14/a3/9e59340f02c1dc8f8c0a05b09244712b8609eb5439f9996e887e2b82f452/ipython-9.14.0-py3-none-any.whl", hash = "sha256:8fd984a3372c14b12790b084ba6b5cff5678c0cb063244a0034f06a51f20d6c2", size = 627457, upload-time = "2026-05-29T15:13:22.942Z" }, ] [[package]] @@ -1554,7 +1557,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' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "ipython", version = "9.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "jupyterlab-widgets" }, { name = "traitlets" }, { name = "widgetsnbextension" }, @@ -1626,7 +1629,8 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1712,7 +1716,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' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "ipython", version = "9.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "prompt-toolkit" }, @@ -1814,7 +1818,7 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.6.0" +version = "4.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, @@ -1832,9 +1836,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/3c/1ebd737b860cdbe61eed71536d06aab4e1781fbdcf07ecd5a1afe05b8adf/jupyterlab-4.6.0.tar.gz", hash = "sha256:6a8b88f2aae7ed4d012c634fc957c1a27f3aa217c32f0ced0175fac9ee17f9e5", size = 28181861, upload-time = "2026-06-18T13:52:56.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/2a/d6af53bfd45a43a5bfe7e40ba47ee7a8921a807daf4bb708e3a295bbb54d/jupyterlab-4.6.1.tar.gz", hash = "sha256:75315982ed28427edaa62bb85eadb5105e4043a757643c910efd787fe6ed0837", size = 28179125, upload-time = "2026-06-29T12:48:45.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/eb/aa48075d0aa3d0188db34ba2704f53791757743c0bb02e18c4eef989b6de/jupyterlab-4.6.0-py3-none-any.whl", hash = "sha256:b6938cb8a1ef3d43860ff4745a680c62cc0a9385f9672295bb56cd2e7cfeebe2", size = 17143447, upload-time = "2026-06-18T13:52:51.42Z" }, + { url = "https://files.pythonhosted.org/packages/5a/81/90ac6cc31d248e83a0d1eab343a5e6e68bc783d3f74fbe61640f42a61da4/jupyterlab-4.6.1-py3-none-any.whl", hash = "sha256:85a58546c831f3dce6cf919468c26874c9065e99c42279fb4abb8e1b552a98bb", size = 17164660, upload-time = "2026-06-29T12:48:41.21Z" }, ] [[package]] @@ -2272,7 +2276,7 @@ dependencies = [ { name = "fonttools" }, { name = "kiwisolver" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "packaging" }, { name = "pillow" }, { name = "pyparsing" }, @@ -2738,7 +2742,7 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.4" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", @@ -2746,79 +2750,79 @@ resolution-markers = [ "(python_full_version >= '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.14' and sys_platform != 'darwin')", "(python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'darwin')", ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, - { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, - { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, - { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, - { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, - { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, - { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, - { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] [[package]] @@ -2827,10 +2831,10 @@ version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-bindings", version = "12.9.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "cuda-bindings", version = "13.2.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "cuda-bindings", version = "13.3.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "cuda-core", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "cuda-pathfinder", marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda12') or (python_full_version >= '3.11' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "pywin32", marker = "(python_full_version >= '3.11' and sys_platform == 'win32' and extra == 'extra-13-quantum-pecos-cuda12') or (python_full_version >= '3.11' and sys_platform == 'win32' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (python_full_version < '3.11' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version < '3.11' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform != 'win32' and extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (sys_platform != 'win32' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (sys_platform != 'win32' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform != 'win32' and extra != 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (sys_platform != 'win32' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-15-pecos-workspace-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (sys_platform != 'win32' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] wheels = [ @@ -2932,7 +2936,7 @@ dev = [ ] numpy-compat = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] @@ -3148,7 +3152,7 @@ examples = [ ] numpy-compat = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] @@ -3328,11 +3332,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -3358,30 +3362,30 @@ wheels = [ [[package]] name = "polars" -version = "1.40.1" +version = "1.41.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/8c/bc9bc948058348ed43117cecc3007cd608f395915dae8a00974579a5dab1/polars-1.40.1.tar.gz", hash = "sha256:ab2694134b137596b5a59bfd7b4c54ebbc9b59f9403127f18e32d363777552e8", size = 733574, upload-time = "2026-04-22T19:15:55.507Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/91/74fc60d94488685a92ac9d49d7ec55f3e91fe9b77942a6235a5fa7f249c3/polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd", size = 828723, upload-time = "2026-04-22T19:14:25.452Z" }, + { url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.40.1" +version = "1.41.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ba/26d40f039be9f552b5fd7365a621bdfc0f8e912ef77094ae4693491b0bae/polars_runtime_32-1.40.1.tar.gz", hash = "sha256:37f3065615d1bf90d03b5326222df4c5c1f8a5d33e50470aa588e3465e6eb814", size = 2935843, upload-time = "2026-04-22T19:15:57.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/46/22c8af5eed68ac2eeb556e0fa3ca8a7b798e984ceff4450888f3b5ac61fd/polars_runtime_32-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b748ef652270cc49e9e69f99a035e0eb4d5f856d42bcd6ac4d9d80a40142aa1e", size = 52098755, upload-time = "2026-04-22T19:14:28.555Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/48599a38009ca60ff82a6f38c8a621ce3c0286aa7397c7d79e741bd9060e/polars_runtime_32-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d249b3743e05986060cec0a7aaa542d020df6c6b876e556023a310efd581f9be", size = 46367542, upload-time = "2026-04-22T19:14:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/43/e9/384bc069367a1a36ee31c13782c178dbd039b2b873b772d4a0fc23a2373d/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5987b30e7aa1059d069498496e8dda35afd592b0ac3d46ed87e3ff8df1ad652c", size = 50252104, upload-time = "2026-04-22T19:14:35.945Z" }, - { url = "https://files.pythonhosted.org/packages/15/ef/7d57ceb0651af74194e97ed6583e148d352f03d696090221b8059cdfc90b/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d7f42a8b3f16fc66002cc0f6516f7dd7653396886ae0ed362ab95c0b3408b59", size = 56250788, upload-time = "2026-04-22T19:14:39.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/0f/e4b3ffc748827a14a474ec9c42e45c066050e440fec57e914091d9adda75/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f7becc237a7ec9d9a10878dc8e54b73bbf4e2d94a2991c37d7a0b38590d8f9", size = 50432590, upload-time = "2026-04-22T19:14:43.388Z" }, - { url = "https://files.pythonhosted.org/packages/d9/0b/b8d95fbed869fa4caabe9c400e4210374913b376e925e96fdcfa9be6416b/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:992d14cf191dde043d36fbdbc98a65e43fbc7e9a5024cecd45f838ac4988c1ee", size = 54155564, upload-time = "2026-04-22T19:14:47.239Z" }, - { url = "https://files.pythonhosted.org/packages/06/d9/d091d8fb5cbed5e9536adfed955c4c89987a4cc3b8e73ae4532402b91c74/polars_runtime_32-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:f78bb2abd00101cbb23cc0cb068f7e36e081057a15d2ec2dde3dda280709f030", size = 51829755, upload-time = "2026-04-22T19:14:50.85Z" }, - { url = "https://files.pythonhosted.org/packages/65/ad/b33c3022a394f3eb55c3310597cec615412a8a33880055eee191d154a628/polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537", size = 45822104, upload-time = "2026-04-22T19:14:54.192Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, ] [[package]] @@ -3733,15 +3737,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/cc5a8653e9a24f6cf84768f05064aa8ed5a83dcefd5e2a043db14a1c5f44/python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0", size = 63925, upload-time = "2026-05-05T14:38:39.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/d4/24d543ab8b8158b7f5a97113c831205f5c900c92c8762b1e7f44b7ea0405/python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f", size = 33124, upload-time = "2026-05-05T14:38:38.539Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] [[package]] @@ -3755,7 +3759,7 @@ wheels = [ [[package]] name = "pytket" -version = "2.16.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "graphviz" }, @@ -3764,7 +3768,7 @@ dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "qwasm" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, @@ -3772,21 +3776,21 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/3c/ea309a72e0c1ca8e51f66ee6adb28887bc50af0864192fa1dfbb260a6aba/pytket-2.16.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ea53862f3f8ee2b48b564048d0d6684285abe96aedffbc9f387ead78540648a8", size = 5548141, upload-time = "2026-03-25T14:52:37.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/c6/66ebf277713d915e902b6de0a3e57105874af72e6dc869692229247cb8c9/pytket-2.16.0-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:859597eaf8c50b0dc4ed3c6f5bba01bfaa020995bd84bf9503650f3cb30e9be3", size = 6236954, upload-time = "2026-03-25T14:52:39.746Z" }, - { url = "https://files.pythonhosted.org/packages/02/51/daa28cff7c720cbed715f84c5ea843c571285af33e285afea2a776fedb52/pytket-2.16.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f25a60846202a1e9c5f165fb4112503b96638361f5a7d619f854676045dd7494", size = 7580705, upload-time = "2026-03-25T14:52:41.862Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e4/f47935bbf44a5c68b4f3dee51b4d97ef6417b07b7c34f6401297beaed21e/pytket-2.16.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6b87e9810e80ab57539619ddf6f9d4639cd2aea1a01fb58433e48677aadb00c", size = 8317981, upload-time = "2026-03-25T14:52:43.806Z" }, - { url = "https://files.pythonhosted.org/packages/17/df/291ac431bbe8f0249171e4014e7f1dc9c4b63d961a1b09404ea8d986bb85/pytket-2.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:1e548c94443a934ecd318609988535a3b2741443b12716955328263ee69811f8", size = 9790329, upload-time = "2026-03-25T14:52:45.85Z" }, - { url = "https://files.pythonhosted.org/packages/9b/74/5c28d78be92d9b464908793d950f00125d08ac1f36683803fbdabdd9a80e/pytket-2.16.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e069de503b6fc86d3a46ff4c17ab60374215c3713b0659d0a07eb88b9aee6716", size = 5550081, upload-time = "2026-03-25T14:52:48.711Z" }, - { url = "https://files.pythonhosted.org/packages/22/d9/85e3754d84c90c3fc80a9aa728f2a1335de0c60c6f210449651b6dd80687/pytket-2.16.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:c005ca4b76b1c896110e6ab810a9c466140f66c11b74dcf36f8695b4a4a3c28e", size = 6239367, upload-time = "2026-03-25T14:52:50.648Z" }, - { url = "https://files.pythonhosted.org/packages/7c/84/a7dd8e96c8ebfa2b5a8fcfb31cb7619685cd4bb40363edaf301f20dda2fc/pytket-2.16.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:825c7b8f98f69e0a9e73008131ad53a0e7eb786e373d4f7d4b5b0865b7a4fd5a", size = 7581960, upload-time = "2026-03-25T14:52:53.139Z" }, - { url = "https://files.pythonhosted.org/packages/58/28/63e7429910065a107bc651fe7cf83581ea3c292560c7a2d5ea20591aa582/pytket-2.16.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e55a82f066a31015f4223bc556aefb06c38eedab01d91030af0b4e7c8fffebf2", size = 8318509, upload-time = "2026-03-25T14:52:55.603Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ea/4b2561423c24212d96d63b1655fdcf86c5d9803455b42b5cfe1f96e4c705/pytket-2.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5db78f3600c9626ea050c3a35267097950bfd4e0b002469c066d2a5ffbbca8ab", size = 9791412, upload-time = "2026-03-25T14:52:57.876Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c0/e5fa70ba5d6fcc58ef009938c2037022830ab16d71eba2564abdeff5c40c/pytket-2.16.0-cp312-abi3-macosx_14_0_arm64.whl", hash = "sha256:30e918546c5c9a64157eb59fdbc64a436517ef23b52d0c4ccf04e1707709ae80", size = 5530999, upload-time = "2026-03-25T14:53:00.271Z" }, - { url = "https://files.pythonhosted.org/packages/12/00/387750d1fcf08fc2935b566c71bf7556b810072c28c3415cf495e9b3f625/pytket-2.16.0-cp312-abi3-macosx_15_0_x86_64.whl", hash = "sha256:581d3de487a6098c95b695947104e9ab1a934b5081fa5cf17d78a97ff8b8b701", size = 6220914, upload-time = "2026-03-25T14:53:02.614Z" }, - { url = "https://files.pythonhosted.org/packages/d1/7f/68d933790e9a9353a07147744336468e61df57dd28976665c071ec6ec9e1/pytket-2.16.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58fce2fb058dace4c0335ce83a49dc8061f78c9c363fb5439706435cb9be1797", size = 7532010, upload-time = "2026-03-25T14:53:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/6891f73f189065a8616c44473748eb2c3e4feb8ce4ec85bfa50fb031bb7e/pytket-2.16.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc0a6be06dc03c38423775cdd5bd9e6df81518f9e78128d9deaca20663eff358", size = 8260551, upload-time = "2026-03-25T14:53:07.337Z" }, - { url = "https://files.pythonhosted.org/packages/c8/23/d856913e5c5501e82890791ec9934add317e6f344a7d495e5dac141cc9a0/pytket-2.16.0-cp312-abi3-win_amd64.whl", hash = "sha256:b00f7eb42a84bb77d1b33a460e78392f51026724a1ec7e090a1f018db51c566e", size = 9764004, upload-time = "2026-03-25T14:53:09.658Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8d/7b860651db750067666a74400812b7610847ecd604636574b980a4400b9a/pytket-2.18.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ab80518ee545bc44dff7f40fb68d46cfa946a0d86cbe47227e0c704c57819391", size = 5549168, upload-time = "2026-05-15T08:57:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cb/5a2ca0e39fa3ffed7460ce446fd843824c82dc501966d067b8ab2e0e9988/pytket-2.18.0-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:392384e74f2df432428c12020eb358474cee201b0387eeddac51cace3b2bfd2f", size = 6238136, upload-time = "2026-05-15T08:57:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/ec940c12f8376686d24d0d7d51674a71e45d0441b50bdfada7260e65b6c3/pytket-2.18.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ada57f98fde63a43db5d49407cfda06b70c792c93c018bfee9fb1dd336e07eb6", size = 7583259, upload-time = "2026-05-15T08:57:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/5c/80/0c66b92d7b14c990e90f9d226d69cc1a9d59816b515795ffe63f43045ca3/pytket-2.18.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e50c5368516649e42723256a0b610112f688824fef9535e9adb31d83c1ac359", size = 8319658, upload-time = "2026-05-15T08:57:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/c4/41/87498ac87aa2b5e330b188f6b4614466b451ca64012c7586c5411adcc354/pytket-2.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:6d29b2d1b5277e47ad149b28d5bf13706dd2d3d0502ec577ca21cac4353cc499", size = 9790910, upload-time = "2026-05-15T08:57:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/47/cc/cfcc64549767dee84172ebb5904e1b69d6246099d6ae170b2c91e0e0002c/pytket-2.18.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f700a97c4caf2ac98c47a2c8e37b71b28b9b35ca9218bbb0357bba499b23eeaa", size = 5550955, upload-time = "2026-05-15T08:57:47.038Z" }, + { url = "https://files.pythonhosted.org/packages/27/78/69295578749e0724647d3ba927f4d5466c48dc89218019e3298acb36842d/pytket-2.18.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:9349e1f6faddbe315fe32dd42da8609899b923b607034ff97ae40cb7f5a3d40c", size = 6240572, upload-time = "2026-05-15T08:57:49.046Z" }, + { url = "https://files.pythonhosted.org/packages/37/e1/6d75b33869b710699e939ab0b5193477d5e0f2d825613b07febf3c16c020/pytket-2.18.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3af39d4f3ae3297de8d75773b940800820192e12392071dab7eb3b4c761857", size = 7584459, upload-time = "2026-05-15T08:57:50.761Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/9047f68f21a12f14ca0c1a4fde2e618935afc79f061e2288ded62c468dbb/pytket-2.18.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4817d27ff0ebb5481b64b86eb3ec759b619354c0dff5e4f1a4cfc6e4b888cf78", size = 8320126, upload-time = "2026-05-15T08:57:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/7dbbd09d136084cb07f4aff4fc8cae03791e16c026713e6a72b9b170c601/pytket-2.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:60d95245695257642853ff9b0dec31fd520c9c07cdba4d4fb429a861914b97fe", size = 9791813, upload-time = "2026-05-15T08:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/15/39/28cd32ee7606b1b252b29464ba9afaa2c59cfa4a21fea715c9ed150dfe4e/pytket-2.18.0-cp312-abi3-macosx_14_0_arm64.whl", hash = "sha256:9673824105eb781ae05c4c80d3cd6786f4ab58c5a5def5b9ca7ae890dce9fd2d", size = 5531855, upload-time = "2026-05-15T08:57:57.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/03df743d1349f649a96e26eab7726288b03e9a84d2ecaf6eb9f6e25760e5/pytket-2.18.0-cp312-abi3-macosx_15_0_x86_64.whl", hash = "sha256:c819bae62bd96020053ad872a7e85150509781c2a83d7a66a69b9c6b5f6329cb", size = 6223192, upload-time = "2026-05-15T08:57:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e5/9886826e5ccb5639e493567073e04dc2911972dd8ea095367305f546df13/pytket-2.18.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c805946d49d93d13bb3b4fe21f69e89be38f9b1d5739aea8f64cd1e70c85da4d", size = 7534626, upload-time = "2026-05-15T08:58:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/01/00/b50f3b11b92d5c74713d37ddb57e310a0fcc63eb13549007167985801286/pytket-2.18.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ca100f33ca3b56f8fe9f1d72c904067789cfc58bc084439f9aa409ec6621fc3", size = 8262137, upload-time = "2026-05-15T08:58:03.839Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ce/87fe3c1d0cb981f076d0cccde68043734ec57587a3f8049a675aae0e29e8/pytket-2.18.0-cp312-abi3-win_amd64.whl", hash = "sha256:9557d36cfe4b4376c36c071b0ac792038314ebec396df8252143ac4a1972a7b3", size = 9764538, upload-time = "2026-05-15T08:58:05.654Z" }, ] [[package]] @@ -4033,92 +4037,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] -[[package]] -name = "pyzstd" -version = "0.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/82/7bcafbf06ee83a66990ce5badbb8f4dc32184346bab20de7e468b1a2f6ec/pyzstd-0.18.0.tar.gz", hash = "sha256:81b6851ab1ca2e5f2c709e896a1362e3065a64f271f43db77fb7d5e4a78e9861", size = 806048, upload-time = "2025-10-05T08:19:47.994Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/92/a9f14aba213e39c5994e01343f4ec679fbecc303f60cd26a65b2687826ce/pyzstd-0.18.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:79bb84d866bf57ad2c4bc6b8247628b38e965c4f66288f887bf90f546a42ae04", size = 369087, upload-time = "2025-10-05T08:17:38.272Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/99688133d11bb2fb9fdfb13e8391742c34750927b2091f3a93bc98ccaf99/pyzstd-0.18.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0576c48e2f7a2c457538414a6197397c343b1bf5bfe9332b049afd0366c0c92", size = 295161, upload-time = "2025-10-05T08:17:40.377Z" }, - { url = "https://files.pythonhosted.org/packages/04/d7/84e4ea7f2d429454ee1abc8bbccd6d7a288edbd014fd960f914af807fe06/pyzstd-0.18.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7702484795ee3c16c48a03d990123e833f1e1d6baabbe9a53256238eb04cbc", size = 408550, upload-time = "2025-10-05T08:17:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2f/84dbb3fa0f87077450e795863cc408ee94fb24f85e76428f38a98902e25d/pyzstd-0.18.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c412ac29a9ebb76c8c40f2df146327b460ce184bbbdaa5bc9257317dce4caa8", size = 514924, upload-time = "2025-10-05T08:17:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/48/07/8a326a0a050bdba20ac75254f472fa0c305cd31518c8d05451ef4ad8c256/pyzstd-0.18.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:36baae4201196c2ec6567faf4a3f19c68211efc2fca30836c885b848ed057f66", size = 573393, upload-time = "2025-10-05T08:17:44.444Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6c/bccc029d5c304d9315d8a11d606ee50c31b822e8bce1a7e2be300969e282/pyzstd-0.18.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f6d9c8a535af243c5a19f2d66c3733595ab633e00b97237d877e70e8389edc5", size = 428790, upload-time = "2025-10-05T08:17:45.666Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a1/a21bb405bb405f516581529eccb1b057db391246af31f8755072ed08716a/pyzstd-0.18.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a533550740ce8c721aae27b377fb1160df68a9f457f16015ec8e47547a033dfc", size = 416564, upload-time = "2025-10-05T08:17:47.185Z" }, - { url = "https://files.pythonhosted.org/packages/38/58/9ca3eb20d047f23db0e1d99cd81e6889d9d7e7b3dde3ddae8894f7e0f75a/pyzstd-0.18.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdd76049c8ccbb98276cfa78d807b4a497ec6bad2603361eceae993c6130e5bf", size = 519751, upload-time = "2025-10-05T08:17:48.745Z" }, - { url = "https://files.pythonhosted.org/packages/13/7c/156bcc5f499eb6ca0eafba3f716cda01d8d5119270fab7ac5ca0519b0c3a/pyzstd-0.18.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:09b73fe07a8d81898ef1575cb3063816168abb3305c1a9f30110383b61a4ee92", size = 563494, upload-time = "2025-10-05T08:17:50.298Z" }, - { url = "https://files.pythonhosted.org/packages/11/c3/511560f2043c88dee9a87a8db750d2b805e9be8054f35414e145acade401/pyzstd-0.18.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6baf9fd75d0af4f5d677b6e2d8dd3deb359c4ec2250c8536fe5ea48fd9305199", size = 433321, upload-time = "2025-10-05T08:17:51.46Z" }, - { url = "https://files.pythonhosted.org/packages/11/1e/6d676a5403acbc422e79637c997235d5fc1a2d1fa234a9b22dd4c240fe29/pyzstd-0.18.0-cp310-cp310-win32.whl", hash = "sha256:c0634ab42226d2ad96c94d57fd242df2ca9417350c2969eb97c8c61d9574ba69", size = 220852, upload-time = "2025-10-05T08:17:53.001Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b5/b1389a474d04c59dc50b8546032e5eaac67718025db9efe8dabe04d5b6f9/pyzstd-0.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:ec99569321a99b9868666c85a5846151f9a16b6a222b59b2570e2ddeefd4d80c", size = 249552, upload-time = "2025-10-05T08:17:54.463Z" }, - { url = "https://files.pythonhosted.org/packages/13/2d/6a6bcee3f94a44bda87a3fc8619496ab02736979e373f2dc74d0d9b083fb/pyzstd-0.18.0-cp310-cp310-win_arm64.whl", hash = "sha256:85371149cc1d8168461981084438b9f2f139c1699e989fef44562f7504ba0632", size = 222621, upload-time = "2025-10-05T08:17:55.573Z" }, - { url = "https://files.pythonhosted.org/packages/63/d5/c81a3b2b2ddfd534552649344f7f9dc48c05c48b0b3e4065eb12209d37b0/pyzstd-0.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:848914835a8a984d4c5fad2355dc66f0aca979b35ec22753c9e694be8e98403c", size = 369088, upload-time = "2025-10-05T08:17:57.063Z" }, - { url = "https://files.pythonhosted.org/packages/b3/00/9c9b0afba9a9bad95cd1bc16203058296a3c2b28040bb0decccd8d662c10/pyzstd-0.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3938fea87fe83113b5d8ec2925bb265b4c540e374bb0ec73e5528de58d68c393", size = 295160, upload-time = "2025-10-05T08:17:58.714Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f4/d71aafa852232dec6ec0d34a45b12136958042079703e60314e4a48bab5d/pyzstd-0.18.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9af4bcde7dde46ca7e82a4c6f5fda1760bcbfd15525dbea36fe625263ef06b5e", size = 408553, upload-time = "2025-10-05T08:17:59.889Z" }, - { url = "https://files.pythonhosted.org/packages/27/5b/6af21645f1eb9e22416297dd1bc92615b89f91e5a657d37c528872a566ad/pyzstd-0.18.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d9419d173d26de25342235256aba363190e48e3fd8a8988420a26221b45320", size = 514931, upload-time = "2025-10-05T08:18:01.208Z" }, - { url = "https://files.pythonhosted.org/packages/7d/19/38c3440b82b227d3c99f1e4283584903633bba7e5a8bf757ffce3e913efb/pyzstd-0.18.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b84f75f0494087afad31363e80a3463d1f32a0a6265f1a24660e6422b2b6fa6", size = 573396, upload-time = "2025-10-05T08:18:02.48Z" }, - { url = "https://files.pythonhosted.org/packages/36/b9/7ee22f141b0438c2082cde550ced698ee2490ce5c0acd461d7f8fc2db18a/pyzstd-0.18.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cfcdf0e46020bda2e98814464ca3ae830da83937c4c61776bf8835c7094214e", size = 428794, upload-time = "2025-10-05T08:18:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8f/b6a16829534fa3eac8ef40cc95dbf44a0c2b36a366829b49486a133e5d6b/pyzstd-0.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8551b6bc3690fb76e730967a628b6aab0d9331c38a41f5cddb546be994771191", size = 416581, upload-time = "2025-10-05T08:18:05.357Z" }, - { url = "https://files.pythonhosted.org/packages/6d/dc/601f56488064483358e90005169eecdec33fdb41d10fef825ca8cea6f1ea/pyzstd-0.18.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6883b47a4d5d5489890e24e74ef14c1f16dcd68bb326b86911ae0e254e33e4b7", size = 519760, upload-time = "2025-10-05T08:18:06.614Z" }, - { url = "https://files.pythonhosted.org/packages/70/d9/98328d76d9ce3bf5479af62dfe2f141250c60e210cd9f1a9ed9e14ebc978/pyzstd-0.18.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929dec930296362ce03fee81877fa93a68ca4de3af75fdfa96ecbe0e366b2ee3", size = 563496, upload-time = "2025-10-05T08:18:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/bb/80/ba2cb4769035014a4d272c06414404b9652bbfc14562ab59a9b8ffd9b1eb/pyzstd-0.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:278c80fdeaf857b620295cc815a31f6478fcb217d476ac889985a43b2b67e9bd", size = 433325, upload-time = "2025-10-05T08:18:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/31/72/5303be53d439500067195c754ec31a8d9e8b2e95a6089ecd3f7354d9134e/pyzstd-0.18.0-cp311-cp311-win32.whl", hash = "sha256:0d1b678644894e49b5a448f02eebe0ac31bde6f51813168f5ff223d7212e1974", size = 220845, upload-time = "2025-10-05T08:18:11.706Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4c/12f74c81d8e43e42338295347223bb37edf9d13a149690fd92e91a083664/pyzstd-0.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:8285a464aed201b166bb0d2f4667485b61b607cf89f12943b1f21f7e84cb4550", size = 249578, upload-time = "2025-10-05T08:18:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/e7/5f/64480ea0ead505f3d30dfa46de9e9f72d7eb0325612ace435f84fafca780/pyzstd-0.18.0-cp311-cp311-win_arm64.whl", hash = "sha256:942badf996589e5ab6cbdd0f7dd33f5dc2cd7ed0b65441c96b9a12ffa7700d51", size = 222617, upload-time = "2025-10-05T08:18:14.591Z" }, - { url = "https://files.pythonhosted.org/packages/8f/19/7c78cf4cedb812362bb77d0ad5c7e0fa843a344d5d5737a55dd1c1c2b987/pyzstd-0.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5eef13ee3e230e50c01b288d581664e8758f7b831271f6f32cfc29823a6ab365", size = 369768, upload-time = "2025-10-05T08:18:16.049Z" }, - { url = "https://files.pythonhosted.org/packages/24/97/b01f76d7a9d7237d9b1dee94469d638575268635ba22c1d07acc23ee3ead/pyzstd-0.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f78d6ef80d2f355b5bc1a897e9aa58659e85170b3fa268f3211c4979c768264c", size = 295766, upload-time = "2025-10-05T08:18:17.234Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ac/2f629bb68c545d2f65e070b69a4aad39418b874ad61715d5f12014a06a74/pyzstd-0.18.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:394175aeeb4e2255ff5340b32f6db79375b3ffb25514fe4c1439015a7f335ec2", size = 409439, upload-time = "2025-10-05T08:18:18.397Z" }, - { url = "https://files.pythonhosted.org/packages/fa/cc/b9bab32be36cf4552940c5bda281e1480b01cbf12b5852b529f69a6f33e8/pyzstd-0.18.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3250c551f526d3b966cf4a2199a8d9538dc5c7083b7a26a45f305f8f2ab20a06", size = 516327, upload-time = "2025-10-05T08:18:19.597Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/c208c74ba04fa5fcef421fd728e906cd1570f8bffb77285c8f9418892bd4/pyzstd-0.18.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a99ca80053ca37be21f05f6c4152c70777e0eface72b08277cb4b10b6d286e79", size = 574893, upload-time = "2025-10-05T08:18:20.824Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/b049d9688f46f8dd3509f5cb0a8ba629716bb9238161c119d8d06454412d/pyzstd-0.18.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5dc4488536e87ff0aac698b9cd65f2913ac87417b3952d80be32463c8e95cc35", size = 429851, upload-time = "2025-10-05T08:18:22.12Z" }, - { url = "https://files.pythonhosted.org/packages/9d/35/dff064fa704a0f335aab326318651af81aac611a8a24e7f1be3b9fb50d32/pyzstd-0.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12da158f6ec1180be0a3d6f531050dfc1357a25e5d0fd8dd99d4506d2a3f448", size = 417437, upload-time = "2025-10-05T08:18:23.264Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2d/d1731fd1213da3a41dcba84e297118c09ed6f05ff0f600c5b0a42edb0f62/pyzstd-0.18.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f9a7d6bff36dfbe87dce1730e4b70d6ab49058a6f8ea22e85b33642491a2d053", size = 521302, upload-time = "2025-10-05T08:18:24.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/89/0a50c7b3f71921a7df41a222283b56fbe236dc79dc142969705676a9eca1/pyzstd-0.18.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0f56086bf8019f7c809a406dcc182ce0fb0d3623a9edf351ed80dbb484514613", size = 564793, upload-time = "2025-10-05T08:18:26.122Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8c/b1927786cd208a1271728969ba45881a823663b6621e2905c1029ad31a15/pyzstd-0.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eb69217ad9b760537e93f2d578c7927b788a9cac0e2104e536855a2797b5b09", size = 434400, upload-time = "2025-10-05T08:18:27.668Z" }, - { url = "https://files.pythonhosted.org/packages/66/f2/9780dc79d4bc9b6f5c64fe91541bf4a15dda9a9bf5eaf4a641436a50b2d0/pyzstd-0.18.0-cp312-cp312-win32.whl", hash = "sha256:05ce49412c7aef970e0a6be8e9add4748bc474a7f13533a14555642022f871e9", size = 221172, upload-time = "2025-10-05T08:18:29.464Z" }, - { url = "https://files.pythonhosted.org/packages/a2/d0/1b5c6e7bbe8e300159edd47556233a7756f59ee6d78b83e95e258e1ceaf9/pyzstd-0.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:e951c3013b9df479cff758d578b83837b2531d02fb6c3e59166a756795697e19", size = 249751, upload-time = "2025-10-05T08:18:30.642Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c2/7b64e5c2afd752fa18bc5a5c6e260dad14402e6f94d810f8ce2ad64ad512/pyzstd-0.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:33b54781c66a86e33c93c89ae426811d0aa35a216a23116fc5d5162449284305", size = 222551, upload-time = "2025-10-05T08:18:31.774Z" }, - { url = "https://files.pythonhosted.org/packages/f5/55/9e200f8ad193bb9d060cd724a8f876e40be918eece578c46c33cab336cf4/pyzstd-0.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:65117997d1e10e9b41336c90c2c4877c8d27533f753272805ff39df15fd5298a", size = 369781, upload-time = "2025-10-05T08:18:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/64/73/28bc86e284ca5e5ea082641748f2a583c9db8744dbf2c28d54e85e101114/pyzstd-0.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8550efbfb5944343666d0e79d6a3687adcbeb4dbf17aa743146a25e72d12d47f", size = 295771, upload-time = "2025-10-05T08:18:34.665Z" }, - { url = "https://files.pythonhosted.org/packages/98/3d/bb6f1316a14bd4079aa3cad13cfd7cd3a1feef4a5756bdb7a9e8758a5ab1/pyzstd-0.18.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac61854c4a77df66695540549a89f4c67039e4181a9158b8646425f1d56d947a", size = 411244, upload-time = "2025-10-05T08:18:35.879Z" }, - { url = "https://files.pythonhosted.org/packages/be/ee/32fdd020003a3bc672b93ec1f9c2520a196bc57e6847a7ff38991dc7b4ea/pyzstd-0.18.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c453369483f67480f86d67a7b63ef22827db65e7f0d4bec7992bb81751a94b9", size = 518289, upload-time = "2025-10-05T08:18:37.657Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c2/0564465315ba5d3524f8bc625af2c125d4549a186bda6fa3b6eafa88d8f4/pyzstd-0.18.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ef4b757b2df808ac15058fc2aa41e07d93843ee5a95629ff51eb6e8f1950951", size = 577193, upload-time = "2025-10-05T08:18:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/de/16/8540b868a5b0fa9eaf743856f86088e3cac8477fbf2339e76c5c973622cc/pyzstd-0.18.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b42529770febd331e23c5e8a68e9899acb0cc0806ee4c970354806c0ceeec6c7", size = 429177, upload-time = "2025-10-05T08:18:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/08ba7b145978b0b5c4d83e26dca8140d71516fed42f60b31a7f387c6c89f/pyzstd-0.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7f54d13c269cdc37d2f73c9b3e70c6d2bb168dec768a472d54c2ed830bb19fb9", size = 419281, upload-time = "2025-10-05T08:18:41.46Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/7f64baf48b060bdc071388c90fb6b32992a5827ab5810d6deced86219ec5/pyzstd-0.18.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6686460ca4be536dca1b6f2f80055f383a78e92e68e03a14806428572c4fdba", size = 523202, upload-time = "2025-10-05T08:18:42.868Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/f5227ebe3ba672ec1051d27546c1d1cb1fde4a84fe8c5fa8cd6bc1b35614/pyzstd-0.18.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8da3978d7de9095cacc5089bd0c435ab84ebd127e0979cd31fa1b216111644af", size = 567497, upload-time = "2025-10-05T08:18:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ca/d067850a8035efc68ccc0399f343e86c18111dcb9ddec2a4b8b2ebe421eb/pyzstd-0.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc87e6e50547cff97e07c3fed9999d79b6327c9c4143c3049a7cfeacb2cdba", size = 433881, upload-time = "2025-10-05T08:18:45.761Z" }, - { url = "https://files.pythonhosted.org/packages/64/26/6c91c0815556ab8f7ddd67e891009a4ba6481b905b84a361697f208b053a/pyzstd-0.18.0-cp313-cp313-win32.whl", hash = "sha256:2dd203f2534b16dea2761394fda4e0f3c465a5109ae6450bdaada67e6ac14a45", size = 221170, upload-time = "2025-10-05T08:18:46.986Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8e/3f6f445ee23864988ae3cfd6cebd3fe79dd42dbaa50538275928082e3df8/pyzstd-0.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:98f43488f88b859291d6bdc51cc7793d1eab17aa9382b17d762944bbb8567c98", size = 249759, upload-time = "2025-10-05T08:18:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/b3/43/1a96bd7bae073ae57c3927ece4cd72355ad46b7153961857d6a77a5bec9d/pyzstd-0.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:cff8922e25e19d8fbd95b53f451e637bc80e826ab53c8777a885d4e99d1c0c2d", size = 222548, upload-time = "2025-10-05T08:18:50.163Z" }, - { url = "https://files.pythonhosted.org/packages/11/37/d82e2bf7aad6df47a0914b47460a242802d6107cb63e3b0f0b02fab911b0/pyzstd-0.18.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:67f795ec745cfd6930cdaf5118fcdd8d87ce02b07b254d37efe75afd33ce9917", size = 369755, upload-time = "2025-10-05T08:18:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/90/1f/b01eb168e7dd689a0df6dcdd5e8171953927330e3b40e290c7a227714e50/pyzstd-0.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a8a589673b9b417a084e393f18d09a16b67b87a80f80da6d3b4f84dd983c9b3d", size = 295789, upload-time = "2025-10-05T08:18:53.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/70/581b01d765833eb12f8b04e048d4d5f803007e491c9ecb57bc1ca810317f/pyzstd-0.18.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdaee8c33f96a6568225e821e6cc33045917628ae0bc7d8d3855332085c1aa7c", size = 411367, upload-time = "2025-10-05T08:18:54.295Z" }, - { url = "https://files.pythonhosted.org/packages/32/ff/9896a01d1eef51e352f6d9d8175f9ff13b394a8239f337c6e0ec930fe3ee/pyzstd-0.18.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42bf45d8e835d7c9c0bef98ff703143a5129edf09ef6c3b757037cbf79eabcaa", size = 518386, upload-time = "2025-10-05T08:18:55.472Z" }, - { url = "https://files.pythonhosted.org/packages/26/24/60cebe2101a3915535a480f77c0acc1e2b9d690377ef72e0237594def37e/pyzstd-0.18.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f4dff2a15e2047baea9359d3a547dee80f61887f17e0f23190b4b932fd617e4", size = 577251, upload-time = "2025-10-05T08:18:56.789Z" }, - { url = "https://files.pythonhosted.org/packages/7e/d0/86beb2bf4b112c46eb39c24389561f979672f262dcb76f3b500c7ab47fb1/pyzstd-0.18.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ed87932d6c534fc8921f7d44a4dadb32881e10ebc68935175a2cba254f5cc83", size = 429102, upload-time = "2025-10-05T08:18:57.973Z" }, - { url = "https://files.pythonhosted.org/packages/be/a4/1037092d5ff6fc11db687ae71413c51eef59b3fc6899a4777328eafcc7b3/pyzstd-0.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7d08a372b2b7fa1fd24217424e13d3d794e01299c43c8bd55f50934ef0785779", size = 419324, upload-time = "2025-10-05T08:18:59.297Z" }, - { url = "https://files.pythonhosted.org/packages/b3/97/13855620caa4f00262b819b0962dbf66e798f17523fd770edea5eb471e52/pyzstd-0.18.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:e8403108172e24622f51732a336a89fe32bf3842965e0dc677c65df3a562f3ad", size = 523380, upload-time = "2025-10-05T08:19:01.022Z" }, - { url = "https://files.pythonhosted.org/packages/a4/20/7f0b24d7da75637094191847df620e6a3de954742a2899c89c0f80fd9f71/pyzstd-0.18.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5604eeb7f00ec308b7e878dae92abfc4eee2e5d238765a62d4fadc0d57bbbff3", size = 567596, upload-time = "2025-10-05T08:19:02.408Z" }, - { url = "https://files.pythonhosted.org/packages/58/87/389186d0ba732ba4f53dbf3dc82ac08a783e7ea2ab06de21c98ae4ae5a65/pyzstd-0.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6b300c5240409f1e7ab9972ab2a880a1949447d8414dbc11d89c10bfcb31aa5", size = 433862, upload-time = "2025-10-05T08:19:05.258Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6f/bedcdbbfecae9f253eaeaa3776a2876cbd4af66ce710d1e2492546debc61/pyzstd-0.18.0-cp314-cp314-win32.whl", hash = "sha256:83f4fe1409a59c45a5e6fccb4d451e1e3dd03a5fabebd2dd6ba651468f54025e", size = 225481, upload-time = "2025-10-05T08:19:06.525Z" }, - { url = "https://files.pythonhosted.org/packages/df/04/85bc2d0d906762c84f6e2f2be7228c1f610b8d42dfbb0602b281ee14bd9d/pyzstd-0.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:73c3dcd9a16f1669ed6eef0dad1d840b7dd6070ab7d48719171ca691101e7975", size = 255101, upload-time = "2025-10-05T08:19:07.807Z" }, - { url = "https://files.pythonhosted.org/packages/64/5c/19e973213762479698dd551b39e7841fcc16104f492fdf941daa42637633/pyzstd-0.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:61333bbb337b9746284624ed14f6238838dfae1e395691ba49f227015374f760", size = 229465, upload-time = "2025-10-05T08:19:09.084Z" }, - { url = "https://files.pythonhosted.org/packages/59/79/10e69fe241968e1993b6ea4c09c4f4cb5783a7f639d680958a9835310ed4/pyzstd-0.18.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:35934369fcdfde6fb932f88fa441337c8ddaf4b08e7b0b12952010f0ba2082f7", size = 354239, upload-time = "2025-10-05T08:19:28.84Z" }, - { url = "https://files.pythonhosted.org/packages/73/a2/f0e6b6a816693d37840342d1e6a81fa1504c9c0c825f504f80c2ebc9dd91/pyzstd-0.18.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:55b8e12c9657359a697440e88a8535d1a771025e5d8f1c3087ad69ba11bee6d2", size = 283761, upload-time = "2025-10-05T08:19:30.494Z" }, - { url = "https://files.pythonhosted.org/packages/99/c3/3f22f0ded2cd19e506eee9ea270854919d4d2df5006fbb5faef3d561b0f6/pyzstd-0.18.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134d33d3e56b5083c8f827b63254c2abf85d6ace2b323e69d28e3954b5b71883", size = 338432, upload-time = "2025-10-05T08:19:31.83Z" }, - { url = "https://files.pythonhosted.org/packages/22/62/ac46ca28d6dd56e22207a86e1315b5b2a2c1c1d23b08e1079c07ebbff1e7/pyzstd-0.18.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6c4bffa0157ef9e5cfa32413a5a79448e5affadece4982df274f1b5aae3a680", size = 358420, upload-time = "2025-10-05T08:19:33.059Z" }, - { url = "https://files.pythonhosted.org/packages/38/ce/ec54eeca984a67d4785f0d1fad5179de1781a38adee80a7ecb51a1c5edf9/pyzstd-0.18.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8c36824d94cf77997a899b60886cc2be3ac969083f1d74eb4dd4127234ba50a4", size = 244901, upload-time = "2025-10-05T08:19:34.321Z" }, - { url = "https://files.pythonhosted.org/packages/25/cd/2dafdf61d6d092cc331aa5310ba34a42fa528ae3f3ab0e505b6b5728a56a/pyzstd-0.18.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:788e0889db436cd6d16a3b490006ab80a913d8ce6f46db127f1888066ff4560b", size = 354192, upload-time = "2025-10-05T08:19:35.549Z" }, - { url = "https://files.pythonhosted.org/packages/af/ed/89a2c9144da69dcf8113fa2f3b6d234a17e19ab37f599cc2270bb1ee394e/pyzstd-0.18.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e70b7c36a40d7f946bf6391a206374b057299735d366fad6524d3b9f392441f", size = 283718, upload-time = "2025-10-05T08:19:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b1/179601c6972cea615af936de9ba8225be9d53c2cfd0a2a7ceacf32a22857/pyzstd-0.18.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:571c5f71622943387370f76de8cc0de3d5c6217ab0f38386cb127665e4e09275", size = 338432, upload-time = "2025-10-05T08:19:38.073Z" }, - { url = "https://files.pythonhosted.org/packages/b8/57/067cf55f86a4baf8cd9e91f7e9596038cabc2b296ac3e0f9be14310c435f/pyzstd-0.18.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de0b730f374b583894d58b79cff76569540baf1e84bc493be191d3128b58e559", size = 358419, upload-time = "2025-10-05T08:19:39.34Z" }, - { url = "https://files.pythonhosted.org/packages/49/88/53d1ec8c639305fb96944b3a1e7f60b6e6af80781d970036c3cf2d6d2316/pyzstd-0.18.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b32184013f33dba2fabcdda89f2a83289f5b717a0c2477cda764e53fdafec7ee", size = 244902, upload-time = "2025-10-05T08:19:40.607Z" }, -] - [[package]] name = "qir-qis" version = "0.1.8" @@ -4177,8 +4095,8 @@ requires-dist = [ { name = "cupy-cuda13x", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=13.0.0" }, { name = "cuquantum-python-cu12", marker = "python_full_version >= '3.11' and extra == 'cuda12'", specifier = ">=25.3.0" }, { name = "cuquantum-python-cu13", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=25.9.0" }, - { name = "guppylang", specifier = ">=0.21.6" }, - { name = "hugr", specifier = ">=0.13.0" }, + { name = "guppylang", specifier = ">=0.21.16,<0.22" }, + { name = "hugr", specifier = ">=0.16,<0.17" }, { name = "matplotlib", marker = "extra == 'visualization'", specifier = ">=2.2.0" }, { name = "networkx", specifier = ">=2.1.0" }, { name = "pecos-rslib", editable = "python/pecos-rslib" }, @@ -4191,7 +4109,7 @@ requires-dist = [ { name = "quantum-pecos", extras = ["visualization"], marker = "extra == 'all'", editable = "python/quantum-pecos" }, { name = "selene-sim", specifier = "~=0.2.0" }, { name = "stim", marker = "extra == 'stim'", specifier = ">=1.15.0" }, - { name = "tket", specifier = "<0.12.16" }, + { name = "tket", specifier = ">=0.13,<0.14" }, ] provides-extras = ["stim", "visualization", "all", "cuda13", "cuda12"] @@ -4213,7 +4131,8 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -4223,7 +4142,7 @@ wheels = [ [[package]] name = "requests" -version = "2.34.0" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -4231,9 +4150,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -4286,6 +4205,10 @@ wheels = [ name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')", +] sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, @@ -4404,29 +4327,172 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.14' and sys_platform != 'darwin')", + "(python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'darwin')", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] [[package]] @@ -4500,7 +4566,7 @@ resolution-markers = [ "(python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'darwin')", ] dependencies = [ - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4568,7 +4634,7 @@ wheels = [ [[package]] name = "selene-core" -version = "0.2.9" +version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, @@ -4584,7 +4650,7 @@ dependencies = [ { name = "ziglang" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/a9/a94ad38852346869a9c8da037a92cff867b2fe72e9f512bae561a6bc6d23/selene_core-0.2.9-py3-none-any.whl", hash = "sha256:847c78ea393de43e736adf20c3aa7006ae8f96765299a401abbbda6af9af3128", size = 33096, upload-time = "2026-04-28T12:47:34.292Z" }, + { url = "https://files.pythonhosted.org/packages/13/33/32906365f8359588d7d05301a2e645563321bf39a11f076ee16f7aa5178a/selene_core-0.2.10-py3-none-any.whl", hash = "sha256:c53857781c25ce95e170dcf83044ba152e68d799a3585f4874281b306a24899c", size = 33014, upload-time = "2026-05-22T10:07:21.106Z" }, ] [[package]] @@ -4601,22 +4667,22 @@ wheels = [ [[package]] name = "selene-sim" -version = "0.2.15" +version = "0.2.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "pyyaml" }, { name = "selene-core" }, { name = "selene-hugr-qis-compiler" }, { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/05/76931c2c757b9a4ac44a75c3b8b1e50209dd94716dad8bc91f8a0023c344/selene_sim-0.2.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:44f301aeb84de22ab4c0d4a2fc0bfa42a035ae66bc2ba3acbcd6f8c67b0a1284", size = 4055479, upload-time = "2026-04-28T13:13:59.353Z" }, - { url = "https://files.pythonhosted.org/packages/40/1c/90d029fe76a22fbd4462039b6dc35b6574d86775d4cf5fd27da760185d73/selene_sim-0.2.15-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:7d5f67ac8c19b3422e653d697eace0c9599b28b82ea1bdc35cc1c3e09f14145b", size = 4212319, upload-time = "2026-04-28T13:14:00.688Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/87d3e225f32b05da5813bfe1ff8bf6c70f60c60919044ec48cae9c6ce625/selene_sim-0.2.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2667b07b0d826b770e6381f7d78eda7559487f38e04abe37c5f343b7dbb2703b", size = 4498690, upload-time = "2026-04-28T13:14:02.208Z" }, - { url = "https://files.pythonhosted.org/packages/ec/08/25d10f9c2c410b2d17cb2eb1c63bfe5dc84282e71081cd8e9e2b69db969d/selene_sim-0.2.15-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:aaf5dbee4c988d02f42b18fe3208aaec020743c17faf103e3e749b5459e5d059", size = 4672594, upload-time = "2026-04-28T13:14:03.59Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ed/db54b8a8a23533754d2b4fd7befa38097f82c38e532a6b2dc529db3a7cb6/selene_sim-0.2.15-py3-none-win_amd64.whl", hash = "sha256:564e2ab77eebe4e193fdc373c31f24c650302a00877f3735dc1f5d97718e88eb", size = 9602115, upload-time = "2026-04-28T13:14:05.331Z" }, + { url = "https://files.pythonhosted.org/packages/7d/6b/bf8f5775c0d0d56c712955ea591d61c467730a88c9de7694d011adf52dcd/selene_sim-0.2.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6f2dd9b201641236381ddc0e2b6a630a994d154026bd9db7da6bec8fc2caf428", size = 4838048, upload-time = "2026-05-22T10:29:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0e/48a157a1c2f05e783be6725ae22c49b76b5b614e756a7b0016377f128172/selene_sim-0.2.16-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:abed123cb9b38f402b5d90b4163089fbbdb082edbd7a0411937648700986d569", size = 5043163, upload-time = "2026-05-22T10:29:58.46Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/ad52baec846567b0b106e1a0024eaff9fd625c4c7fa0695bd9f73b79d300/selene_sim-0.2.16-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:cfad4682d1dc9d468fb3d8d238ce1be28a3a65b0758a8bf21d40e15e9dc10e24", size = 4886685, upload-time = "2026-05-22T10:29:59.877Z" }, + { url = "https://files.pythonhosted.org/packages/a9/fd/f63cb665ea020aebe09e750ae24302285e25bfdba9e354c36064c7064489/selene_sim-0.2.16-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:27afd571454c478b6cfe4b80c4f4092e1047d61cab393e88700b2b5346ea0d93", size = 5065403, upload-time = "2026-05-22T10:30:01.61Z" }, + { url = "https://files.pythonhosted.org/packages/65/f0/b57d7191763ed941d9945d7c26457f7e0facf2791096965e1d5d80fce6b3/selene_sim-0.2.16-py3-none-win_amd64.whl", hash = "sha256:45c4bc668557e7d44e7cac10a4f852eec2471cd29c8dd8e9ad03f9cb7d1914d3", size = 10408993, upload-time = "2026-05-22T10:30:03.804Z" }, ] [[package]] @@ -4666,11 +4732,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] @@ -4693,7 +4759,7 @@ version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/15/0218eacd61cda992daf398bc36daf9830c8b430157a3ac0c06379598d24a/stim-1.15.0.tar.gz", hash = "sha256:95236006859d6754be99629d4fb44788e742e962ac8c59caad421ca088f7350e", size = 853226, upload-time = "2025-05-07T06:19:30.452Z" } wheels = [ @@ -4763,21 +4829,25 @@ wheels = [ [[package]] name = "tket" -version = "0.12.13" +version = "0.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, - { name = "pytket" }, { name = "tket-eccs" }, { name = "tket-exts" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/9f/b80e8f9f93e4f6f5af27170a9484f36de1171c939a55b132ad08b43317e7/tket-0.12.13.tar.gz", hash = "sha256:d6b394f3b27e2d9e67438a007592d1c980bee19b0544a7735972aa162543adeb", size = 460359, upload-time = "2025-12-10T18:07:02.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/b0/b6f4659447c521c3b7fe066efa6b3204d2eb1129de8b31fb0ed2218d5596/tket-0.13.1.tar.gz", hash = "sha256:aaf21432939923962362b8783be8190e7f493ae5db3b783fdb93abd352d39ec4", size = 605360, upload-time = "2026-05-19T13:31:30.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/30/a755e7b4365c4c89294f152e342734c3d4e9ad3e2ba5ba9b8bbd2f516b11/tket-0.12.13-cp310-abi3-macosx_13_0_arm64.whl", hash = "sha256:8a0ba6c6e7624ba66b908bba1ef88dc9f8165e147a12c69374c121a392e71acc", size = 10291588, upload-time = "2025-12-10T18:06:51.074Z" }, - { url = "https://files.pythonhosted.org/packages/de/09/22f00a9c639e61619980008f8f50cbc6966c55790f309160915e139f54a3/tket-0.12.13-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:c7ec567d8d13fff896f06540c6f5ba61ef0cd2da2850a21b444850dc9b677de5", size = 11275557, upload-time = "2025-12-10T18:06:53.498Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ee/a25579314ec8058d0e96b3e26d7f6862383d0bcf1d150302bb2be543f416/tket-0.12.13-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f60f2a412da68e34328b101b135f807334cd830e97e9b3992b62e708b14c8b1", size = 13376604, upload-time = "2025-12-10T18:06:55.756Z" }, - { url = "https://files.pythonhosted.org/packages/4c/57/bef91d6aba87ad78778a211255bfd64d9a942ad991b17c64f4bc577fd07c/tket-0.12.13-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfff346a679334a8f21035abe8867ef36335ffe28e0edbab965a667310294fd5", size = 14891058, upload-time = "2025-12-10T18:06:58.363Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ae/da9b17cedf6f8080a0edec5654b687ae967aca4d6bd0d5f1806b625fbea7/tket-0.12.13-cp310-abi3-win_amd64.whl", hash = "sha256:fb9b5592f58462aaf8769fda6355350a935c0061a344d2bae0a5ed4a16ea907e", size = 10183807, upload-time = "2025-12-10T18:07:00.726Z" }, + { url = "https://files.pythonhosted.org/packages/44/d0/3c9a0d7152d3cf01efd76cf32ae92b79dca7acae14a44b64845ddeb6b6c2/tket-0.13.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2c64dc53ea903350491927260cfdaf8d77dd1400c786be9b83305b4b9dee2e96", size = 10441428, upload-time = "2026-05-19T13:31:18.158Z" }, + { url = "https://files.pythonhosted.org/packages/96/41/c00db203f438f14fb892a98387cc476e7c506ad7bff26a1756a53a07f173/tket-0.13.1-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:bcfcbc0f646f5f8c16e943819a1010aaaf312c79642ea6e737ad7b9559603f8b", size = 11378534, upload-time = "2026-05-19T13:31:20.779Z" }, + { url = "https://files.pythonhosted.org/packages/33/b0/90894ebb09b8eeda60da8205e74d892183007c93433e9ffab052a365abb7/tket-0.13.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d53963635634af92670537fa431450a58e36b79ffcc7d8c7b0731150443ed2ea", size = 12555543, upload-time = "2026-05-19T13:31:23.191Z" }, + { url = "https://files.pythonhosted.org/packages/bd/70/68edcddf60219455933938502377a5e224873ccdfdc9e2ed6381b01fb690/tket-0.13.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:172fd7b159129ff46652474e85bcceae2eabcf854a712ba465c25818964dc0ed", size = 13628232, upload-time = "2026-05-19T13:31:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f0/416bc9d59675a1330b2d08d2f975e40cf4a3d70de39776b4e7a99506e71c/tket-0.13.1-cp310-abi3-win_amd64.whl", hash = "sha256:7b8c16dbe4af5dbc5a4381c9d17cbba4b103fe60254273f1eec2de08b70736be", size = 10218380, upload-time = "2026-05-19T13:31:28.394Z" }, +] + +[package.optional-dependencies] +pytket = [ + { name = "pytket" }, ] [[package]] @@ -4791,14 +4861,14 @@ wheels = [ [[package]] name = "tket-exts" -version = "0.12.2" +version = "0.12.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/72/b4f7b227dda423c91a5e0a0256d518624d51cf685faf92fbc778104348b6/tket_exts-0.12.2.tar.gz", hash = "sha256:b71230cca9b702bb55de6fd4548211a83ba01349a1b6008ae9359b6fdd24f22b", size = 21670, upload-time = "2026-01-28T16:26:26.757Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/68/036b38ded9ad6ef8aedb93817127af0b5535e52eeb68ef8f19b368d0e035/tket_exts-0.12.3.tar.gz", hash = "sha256:de9faa87e35a7cc2250ab1022a026f129d05dc7bf41ad4fd86914d5a9c81541e", size = 21797, upload-time = "2026-04-07T15:26:51.422Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/74/c0be1c952d7ae4145dff4598fc554d17f08ef914bafcb25ea5ad84da01eb/tket_exts-0.12.2-py3-none-any.whl", hash = "sha256:297913ca71bda2488741d2cef09eec5fc5f387b6b18958e819dfa28159551146", size = 34322, upload-time = "2026-01-28T16:26:25.615Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/ab123f36707d0fb4deaa96c48a992b90f574348f79aefb94c120dd695f1b/tket_exts-0.12.3-py3-none-any.whl", hash = "sha256:34b03fa69160606bb7ef86735671aa8e7a70149947d630c48a7ebf2f61c49417", size = 34309, upload-time = "2026-04-07T15:26:49.84Z" }, ] [[package]] @@ -4895,26 +4965,26 @@ wheels = [ [[package]] name = "types-requests" -version = "2.33.0.20260508" +version = "2.33.0.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/6b/eb226bdd61a982c9a03e02c657fb4ab001733506e6423906ac142331f2e3/types_requests-2.33.0.20260508.tar.gz", hash = "sha256:81b2ae5f0d20967714a6aa5ef9284c05570d7cb06b7de8f2a77b918b63ddd411", size = 23991, upload-time = "2026-05-08T04:50:56.818Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/96/080db0afdf2c5cc5fe512b41354e8d114fe8f65e9510c56ff8dfd40216ce/types_requests-2.33.0.20260508-py3-none-any.whl", hash = "sha256:fa01459cca184229713df03709db46a905325906d27e042cd4fd7ea3d15d3400", size = 20722, upload-time = "2026-05-08T04:50:55.548Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, ] [[package]] name = "types-tqdm" -version = "4.67.3.20260508" +version = "4.67.3.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/d9/add71c78db72e934747f7467ffe7b8fa9f3e9fb38ffa5377d5dd390ac036/types_tqdm-4.67.3.20260508.tar.gz", hash = "sha256:9acfdd179bdf5cc81f7ce7353b5b85eb92b16667bba89ec6c187b5e7ce617986", size = 18141, upload-time = "2026-05-08T04:52:34.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/8c/8216f9030027dc96c776b0307cb06da9f3c3756ef00e98f1eb332e04a33c/types_tqdm-4.67.3.20260518.tar.gz", hash = "sha256:1f0cf61de56a76a454fbe2c6cc48d05e483470a38f8a9071893b923e7be47190", size = 18208, upload-time = "2026-05-18T06:08:04.417Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/64/e66c98e951deb5985fbff40a11ba0a1f0528505e0734fa6c39534fc0b113/types_tqdm-4.67.3.20260508-py3-none-any.whl", hash = "sha256:0440759cc861a90c1cc98870f2c15ac633c0b6b14651dcafb83f98ab83bad0f4", size = 24546, upload-time = "2026-05-08T04:52:33.995Z" }, + { url = "https://files.pythonhosted.org/packages/56/59/dbe84f71186645b821cf63f71fc746e1455fbce074594a86f486bee8dc89/types_tqdm-4.67.3.20260518-py3-none-any.whl", hash = "sha256:4969f5b97257c6d082c163d7a0715fffe7a83cb6863e108eac45c3fb82305a68", size = 24586, upload-time = "2026-05-18T06:08:03.568Z" }, ] [[package]] @@ -4967,7 +5037,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.1" +version = "21.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -4976,9 +5046,28 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b85580eb09bffab54438a709e7288524094fe781da526c2/virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", size = 7613791, upload-time = "2026-05-05T01:34:31.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, +] + +[[package]] +name = "wasmtime" +version = "42.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/cd/1f110419ed006f91624010f4df4da82490220bd5527650284c97fc758a6c/wasmtime-42.0.0.tar.gz", hash = "sha256:90485655d6e541b817a7baa1b3071b4525d03f76bcb6ad04661774f06a3b02d4", size = 117133, upload-time = "2026-02-24T19:12:53.321Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/4f/f71e641e504111a5a74e3a20bc52d01bd86788b22699dd3fee1c63253cf6/virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35", size = 7594539, upload-time = "2026-05-05T01:34:28.98Z" }, + { url = "https://files.pythonhosted.org/packages/20/cb/f206f7a839d6843b01c041000056bf7aad23cf72fe2333a0c5dad144e0f2/wasmtime-42.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:214e7d294ce1b5adb94f09a870a2ab6759173dc0194bdde74ee4492b477d8392", size = 6829706, upload-time = "2026-02-24T19:12:36.637Z" }, + { url = "https://files.pythonhosted.org/packages/2d/97/d4f5f46eef74e013c3a0caa9b8625bb1c4162e2b9817258596ee6932c019/wasmtime-42.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:cdd9710fad242dde7cb0eacbe48bf902bb1bac6ecbecd3e743c31af463a795c6", size = 7699640, upload-time = "2026-02-24T19:12:38.471Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d2/5b2bf901b0a9b8050d966dff61e353de7cd86dd58679a79e48372ff8b3a6/wasmtime-42.0.0-py3-none-any.whl", hash = "sha256:7a166bd262608806f3295343fcd07ee3e037f931f6d3b0a24ab1cfc7ccc3e8eb", size = 6403639, upload-time = "2026-02-24T19:12:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6f/a40322bdd55809441bab7e1ac707aa38ced3572904a700f1dfb4b2520dcd/wasmtime-42.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:21e3dafd74704de0e7ed7668ab76cc5a9df130b4306befbfcb08ddb29673c784", size = 7483525, upload-time = "2026-02-24T19:12:41.422Z" }, + { url = "https://files.pythonhosted.org/packages/47/04/ef61af9fe9e5c0a8d782c8662302535ee6e6dba1a6929191fa3ea371a491/wasmtime-42.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:411bf05df47c8a36c6b31b6012720ac1251b95fdd155e389b25eb6fbbd7e181c", size = 6493225, upload-time = "2026-02-24T19:12:42.9Z" }, + { url = "https://files.pythonhosted.org/packages/44/54/a774313c19c1c0ae2c1897af697c12178904d67911f42c4a9bdddba68640/wasmtime-42.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ca12269ee88aac6b1f64b5f324abf3c6370ff853338d991292f10cb17b906667", size = 7740997, upload-time = "2026-02-24T19:12:44.453Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5d/fae28526b1d42f0365e4fd6c2a212c7c000e47d7320632018fa45735a06e/wasmtime-42.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:78f9353b9fdc2f6e7ed13e28ce0394533f5a62710b75c00434ac82681f738924", size = 6785820, upload-time = "2026-02-24T19:12:45.777Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ae/5c5e96273a36c70753e8ba4db323dd9b1ccf6fcea4ccad99d458ad2ecf13/wasmtime-42.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba317e879aab71c407e7012f4dc10b221c6daf737496c501005612e11d26e8ee", size = 6810021, upload-time = "2026-02-24T19:12:47.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/68/5c129389f67219a90c3ba0dcf85555249bde9797760f2d715bec03bc198a/wasmtime-42.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9ef6dbd1a2cff21694ba64f27b90a7ab0af61a54d911a59682005830683dc8a", size = 7779984, upload-time = "2026-02-24T19:12:48.642Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e5/6650c9e7ad904c9a6730c4b762b1dfed4f7d7b0e981e3624a6ecd7abb7ed/wasmtime-42.0.0-py3-none-win_amd64.whl", hash = "sha256:3a360a1285457021efe24369490cd719996596f2cbe1aa62dae6ad68179cf0f9", size = 6403647, upload-time = "2026-02-24T19:12:50.373Z" }, + { url = "https://files.pythonhosted.org/packages/44/b2/e93046661deef4d8fee2f40080a28e5ff201cc98d4fb1929a46367c34778/wasmtime-42.0.0-py3-none-win_arm64.whl", hash = "sha256:8caa13a6ee264969449c008da1dcb8f9f6c954800853527714e7fcddbdda9166", size = 5397896, upload-time = "2026-02-24T19:12:51.639Z" }, ] [[package]]